mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 02:58:29 +00:00
feat(core): MCP elicitation support (#35064)
This commit is contained in:
parent
e65477ab1d
commit
efcf2c3f5d
18 changed files with 1171 additions and 669 deletions
|
|
@ -30,11 +30,13 @@ export const createDirSyncContext = (
|
|||
const data = new Proxy({} as State, {
|
||||
get(_, property: keyof State) {
|
||||
if (property === "session_working") return serverSync.session.data.session_working.bind(serverSync.session.data)
|
||||
if (property === "question") return { ...serverSync.session.data.question, global: current()[0].question.global ?? [] }
|
||||
if (sessionFields.has(property)) return serverSync.session.data[property as keyof typeof serverSync.session.data]
|
||||
return current()[0][property]
|
||||
},
|
||||
})
|
||||
const set = ((...input: unknown[]) => {
|
||||
if (input[0] === "question" && input[1] === "global") return (current()[1] as (...args: unknown[]) => unknown)(...input)
|
||||
if (typeof input[0] === "string" && sessionFields.has(input[0])) {
|
||||
return (serverSync.session.set as (...args: unknown[]) => unknown)(...input)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -321,8 +321,9 @@ export async function bootstrapDirectory(input: {
|
|||
retry(() =>
|
||||
input.sdk.v2.form.request.list().then((x) => {
|
||||
const forms: QuestionForm[] = (x.data?.data ?? []).flatMap((form) => (isQuestionForm(form) ? [form] : []))
|
||||
const ids = forms.map((question) => question.sessionID)
|
||||
const grouped = groupBySession(forms)
|
||||
const global = forms.filter((question) => question.sessionID === "global")
|
||||
const grouped = groupBySession(forms.filter((question) => question.sessionID !== "global"))
|
||||
const ids = Object.keys(grouped)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
||||
|
|
@ -330,11 +331,22 @@ export async function bootstrapDirectory(input: {
|
|||
batch(() => {
|
||||
const current = input.session?.data.question ?? input.store.question
|
||||
for (const sessionID of Object.keys(current)) {
|
||||
if (sessionID === "global") continue
|
||||
if (grouped[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.directory !== input.directory) continue
|
||||
if (input.session) input.session.set("question", sessionID, [])
|
||||
if (!input.session) input.setStore("question", sessionID, [])
|
||||
}
|
||||
if (global.length > 0 || input.store.question.global) {
|
||||
input.setStore(
|
||||
"question",
|
||||
"global",
|
||||
reconcile(
|
||||
global.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
const value = reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
|
|
|
|||
|
|
@ -527,7 +527,8 @@ describe("applyDirectoryEvent", () => {
|
|||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.fields[0]?.description).toBe("updated")
|
||||
const form = store.question[sessionID]?.find((x) => x.id === "q_2")
|
||||
expect(form?.mode === "form" ? form.fields[0]?.description : undefined).toBe("updated")
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "form.cancelled", properties: { sessionID, id: "q_2" } },
|
||||
|
|
@ -540,6 +541,34 @@ describe("applyDirectoryEvent", () => {
|
|||
expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_3"])
|
||||
})
|
||||
|
||||
test("tracks global form lifecycles when session content is delegated", () => {
|
||||
const [store, setStore] = createStore(baseState())
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "form.created", properties: { form: questionRequest("q_1", "global") } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
sessionContent: false,
|
||||
})
|
||||
|
||||
expect(store.question.global?.map((x) => x.id)).toEqual(["q_1"])
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "form.cancelled", properties: { sessionID: "global", id: "q_1" } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
sessionContent: false,
|
||||
})
|
||||
|
||||
expect(store.question.global).toEqual([])
|
||||
})
|
||||
|
||||
test("updates vcs branch in store and cache", () => {
|
||||
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
|
||||
const [cacheStore, setCacheStore] = createStore({
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export function applyDirectoryEvent(input: {
|
|||
permission?: State["permission"]
|
||||
}) {
|
||||
const event = input.event
|
||||
if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return
|
||||
if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type) && !isGlobalQuestionEvent(event)) return
|
||||
const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
|
||||
switch (event.type) {
|
||||
case "server.instance.disposed": {
|
||||
|
|
@ -413,3 +413,13 @@ export function applyDirectoryEvent(input: {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isGlobalQuestionEvent(event: { type: string; properties?: unknown }) {
|
||||
if (event.type === "form.created") {
|
||||
const form = (event.properties as { form?: unknown } | undefined)?.form
|
||||
return isQuestionForm(form) && form.sessionID === "global"
|
||||
}
|
||||
if (event.type !== "form.replied" && event.type !== "form.cancelled") return false
|
||||
const properties = event.properties as { sessionID?: unknown } | undefined
|
||||
return properties?.sessionID === "global"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import {
|
|||
loadReferencesQuery,
|
||||
} from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, isGlobalQuestionEvent } from "./global-sync/event-reducer"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import { trimSessions } from "./global-sync/session-trim"
|
||||
import type { ProjectMeta } from "./global-sync/types"
|
||||
|
|
@ -375,7 +375,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||
const event = e.details
|
||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||
|
||||
session.apply(event)
|
||||
if (!isGlobalQuestionEvent(event)) session.apply(event)
|
||||
|
||||
if (directory === "global") {
|
||||
applyGlobalEvent({
|
||||
|
|
|
|||
|
|
@ -12,7 +12,15 @@ import { makeEventListener } from "@solid-primitives/event-listener"
|
|||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
import { questionAnswer, type QuestionAnswer, type QuestionForm } from "@/utils/question-form"
|
||||
import {
|
||||
questionAllowsCustom,
|
||||
questionAnswer,
|
||||
questionLabel,
|
||||
questionMessage,
|
||||
questionOptions,
|
||||
type QuestionAnswer,
|
||||
type QuestionForm,
|
||||
} from "@/utils/question-form"
|
||||
|
||||
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
|
||||
|
||||
|
|
@ -67,8 +75,8 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
const language = useLanguage()
|
||||
const cacheKey = ScopedKey.from(serverSDK().scope, props.request.id)
|
||||
|
||||
const questions = createMemo(() => props.request.fields)
|
||||
const total = createMemo(() => questions().length)
|
||||
const questions = createMemo(() => (props.request.mode === "form" ? props.request.fields : []))
|
||||
const total = createMemo(() => (props.request.mode === "url" ? 1 : questions().length))
|
||||
|
||||
const cached = cache.get(cacheKey)
|
||||
const [store, setStore] = createStore({
|
||||
|
|
@ -90,17 +98,23 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
let focusFrame: number | undefined
|
||||
|
||||
const question = createMemo(() => questions()[store.tab])
|
||||
const options = createMemo(() => question()?.options ?? [])
|
||||
const custom = createMemo(() => question()?.custom !== false)
|
||||
const options = createMemo(() => questionOptions(question()))
|
||||
const custom = createMemo(() => questionAllowsCustom(question()))
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
const on = createMemo(() => custom() && store.customOn[store.tab] === true)
|
||||
const multi = createMemo(() => question()?.type === "multiselect")
|
||||
const count = createMemo(() => options().length + (custom() ? 1 : 0))
|
||||
|
||||
const summary = createMemo(() => {
|
||||
if (props.request.title) return props.request.title
|
||||
const n = Math.min(store.tab + 1, total())
|
||||
return language.t("session.question.progress", { current: n, total: total() })
|
||||
})
|
||||
const body = createMemo(() => {
|
||||
if (props.request.mode === "url") return props.request.title ?? "Open URL request"
|
||||
return questionLabel(question())
|
||||
})
|
||||
const message = createMemo(() => questionMessage(props.request))
|
||||
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
|
||||
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
|
||||
|
||||
|
|
@ -154,11 +168,11 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i))
|
||||
|
||||
const pickFocus = (tab: number = store.tab) => {
|
||||
const list = questions()[tab]?.options ?? []
|
||||
if (questions()[tab]?.custom !== false && store.customOn[tab] === true) return list.length
|
||||
const list = questionOptions(questions()[tab])
|
||||
if (questionAllowsCustom(questions()[tab]) && store.customOn[tab] === true) return list.length
|
||||
return Math.max(
|
||||
0,
|
||||
list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
|
||||
list.findIndex((item) => store.answers[tab]?.includes(item.value) ?? false),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -228,7 +242,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
sdk().client.v2.session.form.reply({
|
||||
sessionID: props.request.sessionID,
|
||||
formID: props.request.id,
|
||||
formReply: { answer: questionAnswer(props.request.fields, answers) },
|
||||
formReply: { answer: props.request.mode === "url" ? {} : questionAnswer(props.request.fields, answers) },
|
||||
}),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
|
|
@ -268,7 +282,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
|
||||
const answered = (i: number) => {
|
||||
if ((store.answers[i]?.length ?? 0) > 0) return true
|
||||
return questions()[i]?.custom !== false && store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
|
||||
return questionAllowsCustom(questions()[i]) && store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
|
||||
}
|
||||
|
||||
const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
|
||||
|
|
@ -386,10 +400,10 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
if (!opt) return
|
||||
if (multi()) {
|
||||
setStore("editing", false)
|
||||
toggle(opt.label)
|
||||
toggle(opt.value)
|
||||
return
|
||||
}
|
||||
pick(opt.label)
|
||||
pick(opt.value)
|
||||
}
|
||||
|
||||
const commitCustom = () => {
|
||||
|
|
@ -535,11 +549,24 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
overflow: store.minimized ? "hidden" : undefined,
|
||||
}}
|
||||
>
|
||||
{question()?.title}
|
||||
{body()}
|
||||
</div>
|
||||
<Show when={message()}>
|
||||
<div data-slot="question-hint">{message()}</div>
|
||||
</Show>
|
||||
<Show when={!store.minimized}>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
<Show
|
||||
when={props.request.mode === "url"}
|
||||
fallback={
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div data-slot="question-hint">Open this URL, complete the request, then submit.</div>
|
||||
<a href={props.request.mode === "url" ? props.request.url : undefined} target="_blank" rel="noreferrer">
|
||||
{props.request.mode === "url" ? props.request.url : ""}
|
||||
</a>
|
||||
</Show>
|
||||
</Show>
|
||||
<div
|
||||
|
|
@ -557,7 +584,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
{(opt, i) => (
|
||||
<Option
|
||||
multi={multi()}
|
||||
picked={picked(opt.label)}
|
||||
picked={picked(opt.value)}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
disabled={sending()}
|
||||
|
|
|
|||
|
|
@ -49,5 +49,5 @@ export function sessionQuestionRequest(
|
|||
sessionID?: string,
|
||||
include?: (item: QuestionForm) => boolean,
|
||||
) {
|
||||
return sessionTreeRequest(session, request, sessionID, include)
|
||||
return sessionTreeRequest(session, request, sessionID, include) ?? request.global?.find(include ?? (() => true))
|
||||
}
|
||||
|
|
|
|||
36
packages/app/src/utils/question-form.test.ts
Normal file
36
packages/app/src/utils/question-form.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { questionAnswer, type QuestionField } from "./question-form"
|
||||
|
||||
describe("questionAnswer", () => {
|
||||
test("falls back to field defaults", () => {
|
||||
const fields = [
|
||||
{ key: "name", type: "string", default: "Ada" },
|
||||
{ key: "age", type: "integer", default: 42 },
|
||||
{ key: "newsletter", type: "boolean", default: false },
|
||||
{ key: "colors", type: "multiselect", options: [], default: ["red"] },
|
||||
] satisfies QuestionField[]
|
||||
|
||||
expect(questionAnswer(fields, [[], [], [], []])).toEqual({
|
||||
name: "Ada",
|
||||
age: 42,
|
||||
newsletter: false,
|
||||
colors: ["red"],
|
||||
})
|
||||
})
|
||||
|
||||
test("uses explicit answers over defaults", () => {
|
||||
const fields = [
|
||||
{ key: "name", type: "string", default: "Ada" },
|
||||
{ key: "age", type: "number", default: 42 },
|
||||
{ key: "newsletter", type: "boolean", default: false },
|
||||
{ key: "colors", type: "multiselect", options: [], default: ["red"] },
|
||||
] satisfies QuestionField[]
|
||||
|
||||
expect(questionAnswer(fields, [["Grace"], ["36"], ["true"], ["blue"]])).toEqual({
|
||||
name: "Grace",
|
||||
age: 36,
|
||||
newsletter: true,
|
||||
colors: ["blue"],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,49 +1,72 @@
|
|||
import type { FormAnswer, FormFormInfo, FormUrlInfo } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export type QuestionOption = {
|
||||
value: string
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type QuestionField = {
|
||||
key: string
|
||||
title?: string
|
||||
description?: string
|
||||
type: "string" | "multiselect"
|
||||
options?: QuestionOption[]
|
||||
custom?: boolean
|
||||
}
|
||||
export type QuestionField = FormFormInfo["fields"][number]
|
||||
|
||||
export type QuestionForm = {
|
||||
id: string
|
||||
sessionID: string
|
||||
mode: "form"
|
||||
metadata?: { [key: string]: unknown }
|
||||
fields: QuestionField[]
|
||||
}
|
||||
export type QuestionForm = FormFormInfo | FormUrlInfo
|
||||
|
||||
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 }
|
||||
const form = value as { id?: unknown; sessionID?: unknown; mode?: unknown; fields?: unknown; url?: unknown }
|
||||
if (typeof form.id !== "string" || typeof form.sessionID !== "string") return false
|
||||
if (form.mode === "url") return typeof form.url === "string"
|
||||
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)
|
||||
}
|
||||
|
||||
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"
|
||||
return ["string", "number", "integer", "boolean", "multiselect"].includes(String(field.type))
|
||||
}
|
||||
|
||||
export function questionAnswer(fields: ReadonlyArray<QuestionField>, answers: ReadonlyArray<QuestionAnswer>) {
|
||||
const entries = fields.flatMap((field, index): ReadonlyArray<readonly [string, string | string[]]> => {
|
||||
export function questionAnswer(fields: ReadonlyArray<QuestionField>, answers: ReadonlyArray<QuestionAnswer>): FormAnswer {
|
||||
const entries = fields.flatMap((field, index): ReadonlyArray<readonly [string, FormAnswer[string]]> => {
|
||||
const answer = answers[index] ?? []
|
||||
if (answer.length === 0) return []
|
||||
if (answer.length === 0) {
|
||||
if (field.default === undefined) return []
|
||||
if (field.type === "multiselect") return [[field.key, [...field.default]]]
|
||||
return [[field.key, field.default]]
|
||||
}
|
||||
if (field.type === "multiselect") return [[field.key, answer]]
|
||||
if (field.type === "boolean") return [[field.key, answer[0] === "true"]]
|
||||
if (field.type === "number" || field.type === "integer") return [[field.key, Number(answer[0])]]
|
||||
return [[field.key, answer[0] ?? ""]]
|
||||
})
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
|
||||
export function questionOptions(field: QuestionField | undefined): QuestionOption[] {
|
||||
if (!field) return []
|
||||
if (field.type === "boolean")
|
||||
return [
|
||||
{ value: "false", label: "No" },
|
||||
{ value: "true", label: "Yes" },
|
||||
]
|
||||
if (field.type === "string") return field.options ?? []
|
||||
if (field.type === "multiselect") return field.options
|
||||
return []
|
||||
}
|
||||
|
||||
export function questionAllowsCustom(field: QuestionField | undefined) {
|
||||
if (!field) return false
|
||||
if (field.type === "number" || field.type === "integer") return true
|
||||
if (field.type !== "string" && field.type !== "multiselect") return false
|
||||
return questionOptions(field).length === 0 || field.custom === true
|
||||
}
|
||||
|
||||
export function questionLabel(field: QuestionField | undefined) {
|
||||
return field?.title ?? field?.description ?? field?.key ?? "Form"
|
||||
}
|
||||
|
||||
export function questionMessage(form: QuestionForm) {
|
||||
const message = form.metadata?.message
|
||||
return typeof message === "string" && message !== form.title ? message : undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,13 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
|||
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
ElicitationCompleteNotificationSchema,
|
||||
ElicitRequestSchema,
|
||||
GetPromptResultSchema,
|
||||
type ElicitRequestFormParams,
|
||||
type ElicitRequestParams,
|
||||
type ElicitRequestURLParams,
|
||||
type ElicitResult,
|
||||
ListPromptsResultSchema,
|
||||
ListRootsRequestSchema,
|
||||
ListToolsResultSchema,
|
||||
|
|
@ -82,6 +88,22 @@ export interface CallToolResult {
|
|||
readonly content: ReadonlyArray<CallToolContent>
|
||||
}
|
||||
|
||||
export type ElicitationFormParams = ElicitRequestFormParams
|
||||
export type ElicitationParams = ElicitRequestParams
|
||||
export type ElicitationResult = ElicitResult
|
||||
|
||||
export interface ElicitationHandler {
|
||||
readonly create: (input: {
|
||||
readonly server: string
|
||||
readonly params: ElicitationParams
|
||||
readonly signal: AbortSignal
|
||||
}) => Effect.Effect<ElicitationResult, Error>
|
||||
readonly complete: (input: {
|
||||
readonly server: string
|
||||
readonly elicitationID: ElicitRequestURLParams["elicitationId"]
|
||||
}) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface LogMessage {
|
||||
readonly level: LoggingMessageNotification["params"]["level"]
|
||||
readonly logger?: LoggingMessageNotification["params"]["logger"]
|
||||
|
|
@ -123,6 +145,7 @@ export const connect = Effect.fnUntraced(function* (
|
|||
// Only consumed by the remote transport; stdio servers have no auth concept. A provider with no
|
||||
// stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth.
|
||||
authProvider?: OAuthClientProvider,
|
||||
elicitation?: ElicitationHandler,
|
||||
) {
|
||||
const transport: Transport = yield* Effect.gen(function* () {
|
||||
if (config.type === "local") {
|
||||
|
|
@ -149,6 +172,7 @@ export const connect = Effect.fnUntraced(function* (
|
|||
{ name: "opencode", version: InstallationVersion },
|
||||
{
|
||||
capabilities: {
|
||||
...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
|
||||
// https://github.com/anomalyco/opencode/issues/2308
|
||||
roots: {},
|
||||
},
|
||||
|
|
@ -157,6 +181,14 @@ export const connect = Effect.fnUntraced(function* (
|
|||
client.setRequestHandler(ListRootsRequestSchema, () =>
|
||||
Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
|
||||
)
|
||||
if (elicitation) {
|
||||
client.setRequestHandler(ElicitRequestSchema, (request, extra) =>
|
||||
Effect.runPromise(elicitation.create({ server, params: request.params, signal: extra.signal })),
|
||||
)
|
||||
client.setNotificationHandler(ElicitationCompleteNotificationSchema, (notification) =>
|
||||
Effect.runPromise(elicitation.complete({ server, elicitationID: notification.params.elicitationId })),
|
||||
)
|
||||
}
|
||||
|
||||
const exit = yield* Effect.tryPromise({
|
||||
try: (signal) => client.connect(transport, { timeout: config.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT, signal }),
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@ import { Config } from "../config"
|
|||
import { ConfigMCP } from "../config/mcp"
|
||||
import { Credential } from "../credential"
|
||||
import { EventV2 } from "../event"
|
||||
import { Form } from "../form"
|
||||
import { Integration } from "../integration"
|
||||
import { IntegrationConnection } from "../integration/connection"
|
||||
import { Location } from "../location"
|
||||
import { waitForAbort } from "../process"
|
||||
import { MCPClient } from "./client"
|
||||
import { MCPOAuth } from "./oauth"
|
||||
|
||||
|
|
@ -145,6 +147,10 @@ type ServerEntry = {
|
|||
integrationID?: Integration.ID
|
||||
}
|
||||
|
||||
// Temporary MCP elicitation escape hatch. Public Form routes remain session-shaped, but MCP
|
||||
// elicitations are still Location-scoped until the protocol path can attribute them to a Session.
|
||||
const GLOBAL_ELICITATION_SESSION_ID = "global"
|
||||
|
||||
export interface Interface {
|
||||
readonly servers: () => Effect.Effect<ServerInfo[]>
|
||||
readonly tools: () => Effect.Effect<Tool[]>
|
||||
|
|
@ -175,6 +181,7 @@ export const layer = Layer.effect(
|
|||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const events = yield* EventV2.Service
|
||||
const forms = yield* Form.Service
|
||||
const integration = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const root = yield* Scope.make()
|
||||
|
|
@ -189,6 +196,7 @@ export const layer = Layer.effect(
|
|||
)
|
||||
// Later config files win for duplicate server names; per-server timeout overrides globals.
|
||||
const runtime = new Map<ServerName, ServerEntry>()
|
||||
const urlElicitations = new Map<string, Form.ID>()
|
||||
for (const entry of documents) {
|
||||
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
|
||||
runtime.set(ServerName.make(name), {
|
||||
|
|
@ -308,6 +316,74 @@ export const layer = Layer.effect(
|
|||
})
|
||||
})
|
||||
|
||||
const elicitation = {
|
||||
create: (input: {
|
||||
readonly server: string
|
||||
readonly params: MCPClient.ElicitationParams
|
||||
readonly signal: AbortSignal
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
const toResult = (state: Form.State): MCPClient.ElicitationResult => {
|
||||
if (state.status !== "answered") return { action: "cancel" }
|
||||
if (input.params.mode === "url") return { action: "accept" }
|
||||
return {
|
||||
action: "accept",
|
||||
content: Object.fromEntries(
|
||||
Object.entries(state.answer).map(
|
||||
([key, value]): [string, NonNullable<MCPClient.ElicitationResult["content"]>[string]] => {
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
||||
return [key, value]
|
||||
return [key, Array.from(value)]
|
||||
},
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
if (input.params.mode === "url") {
|
||||
const formID = Form.ID.create()
|
||||
const key = input.server + "\u0000" + input.params.elicitationId
|
||||
urlElicitations.set(key, formID)
|
||||
return yield* forms
|
||||
.ask({
|
||||
id: formID,
|
||||
sessionID: GLOBAL_ELICITATION_SESSION_ID,
|
||||
title: `${input.server} is requesting input`,
|
||||
metadata: {
|
||||
kind: "mcp-elicitation",
|
||||
server: input.server,
|
||||
elicitationID: input.params.elicitationId,
|
||||
message: input.params.message,
|
||||
},
|
||||
mode: "url",
|
||||
url: input.params.url,
|
||||
})
|
||||
.pipe(
|
||||
Effect.raceFirst(waitForAbort(input.signal)),
|
||||
Effect.ensuring(Effect.sync(() => urlElicitations.delete(key))),
|
||||
Effect.map(toResult),
|
||||
)
|
||||
}
|
||||
const params = input.params
|
||||
return yield* forms
|
||||
.ask({
|
||||
sessionID: GLOBAL_ELICITATION_SESSION_ID,
|
||||
title: `${input.server} is requesting input`,
|
||||
metadata: { kind: "mcp-elicitation", server: input.server, message: params.message },
|
||||
mode: "form",
|
||||
fields: Object.entries(params.requestedSchema.properties).map(([key, property]) =>
|
||||
toElicitationField(key, property, params.requestedSchema.required?.includes(key) === true),
|
||||
),
|
||||
})
|
||||
.pipe(Effect.raceFirst(waitForAbort(input.signal)), Effect.map(toResult))
|
||||
}),
|
||||
complete: (input: { readonly server: string; readonly elicitationID: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const formID = urlElicitations.get(input.server + "\u0000" + input.elicitationID)
|
||||
if (!formID) return
|
||||
yield* forms.reply({ id: formID, answer: {} }).pipe(Effect.ignore)
|
||||
}),
|
||||
} satisfies MCPClient.ElicitationHandler
|
||||
|
||||
const toTool = (server: ServerName, def: MCPClient.ToolDefinition) =>
|
||||
new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema })
|
||||
|
||||
|
|
@ -396,7 +472,7 @@ export const layer = Layer.effect(
|
|||
const authProvider = yield* connectProvider(entry)
|
||||
// List tools as part of connect so a failure here marks the server failed rather than
|
||||
// leaving it connected with a silently empty tool list and no path to recover.
|
||||
const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider).pipe(
|
||||
const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider, elicitation).pipe(
|
||||
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))),
|
||||
Scope.provide(scope),
|
||||
Effect.exit,
|
||||
|
|
@ -561,5 +637,74 @@ export const layer = Layer.effect(
|
|||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, Location.node, EventV2.node, Integration.node, Credential.node],
|
||||
deps: [Config.node, Location.node, EventV2.node, Form.node, Integration.node, Credential.node],
|
||||
})
|
||||
|
||||
function toElicitationField(key: string, property: ElicitationProperty, required: boolean): Form.Field {
|
||||
const title = elicitationFieldTitle(key, property)
|
||||
const description = property.description === title ? undefined : property.description
|
||||
const base = {
|
||||
key,
|
||||
...(title === undefined ? {} : { title }),
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(required ? { required: true } : {}),
|
||||
}
|
||||
switch (property.type) {
|
||||
case "boolean":
|
||||
return { ...base, type: "boolean", ...(property.default === undefined ? {} : { default: property.default }) }
|
||||
case "number":
|
||||
case "integer":
|
||||
return {
|
||||
...base,
|
||||
type: property.type,
|
||||
...(property.minimum === undefined ? {} : { minimum: property.minimum }),
|
||||
...(property.maximum === undefined ? {} : { maximum: property.maximum }),
|
||||
...(property.default === undefined ? {} : { default: property.default }),
|
||||
}
|
||||
case "array":
|
||||
return {
|
||||
...base,
|
||||
type: "multiselect",
|
||||
options:
|
||||
"anyOf" in property.items
|
||||
? property.items.anyOf.map((option) => ({ value: option.const, label: option.title }))
|
||||
: property.items.enum.map((value) => ({ value, label: value })),
|
||||
custom: false,
|
||||
...(property.minItems === undefined ? {} : { minItems: property.minItems }),
|
||||
...(property.maxItems === undefined ? {} : { maxItems: property.maxItems }),
|
||||
...(property.default === undefined ? {} : { default: property.default }),
|
||||
}
|
||||
case "string": {
|
||||
const options =
|
||||
"oneOf" in property
|
||||
? property.oneOf.map((option) => ({ value: option.const, label: option.title }))
|
||||
: "enum" in property
|
||||
? property.enum.map((value, index) => ({
|
||||
value,
|
||||
label: ("enumNames" in property ? property.enumNames?.[index] : undefined) ?? value,
|
||||
}))
|
||||
: undefined
|
||||
return {
|
||||
...base,
|
||||
type: "string",
|
||||
...(!("format" in property) || property.format === undefined ? {} : { format: property.format }),
|
||||
...(!("minLength" in property) || property.minLength === undefined ? {} : { minLength: property.minLength }),
|
||||
...(!("maxLength" in property) || property.maxLength === undefined ? {} : { maxLength: property.maxLength }),
|
||||
...(property.default === undefined ? {} : { default: property.default }),
|
||||
...(options === undefined ? {} : { options, custom: false }),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function elicitationFieldTitle(key: string, property: ElicitationProperty) {
|
||||
if (property.title && !isSchemaTypeTitle(property.title)) return property.title
|
||||
if (property.description) return property.description
|
||||
return key
|
||||
}
|
||||
|
||||
function isSchemaTypeTitle(title: string) {
|
||||
return /^(boolean|string|number|integer|array|object)(\s+with\b.*|\s+in\b.*)?$/i.test(title.trim())
|
||||
}
|
||||
|
||||
type ElicitationProperty = MCPClient.ElicitationFormParams["requestedSchema"]["properties"][string]
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ const layer = Layer.effectDiscard(
|
|||
form
|
||||
.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: input.questions.length === 1 ? input.questions[0]?.header : "Questions",
|
||||
...(input.questions.length === 1 ? {} : { title: "Questions" }),
|
||||
metadata: {
|
||||
kind: "question",
|
||||
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
710
packages/tui/src/routes/session/form.tsx
Normal file
710
packages/tui/src/routes/session/form.tsx
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue