mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 00:28:29 +00:00
refactor(form): model links as fields
This commit is contained in:
parent
4c80c23e11
commit
4f8fa72e45
18 changed files with 717 additions and 936 deletions
|
|
@ -544,9 +544,7 @@ export type Endpoint14_2Input = {
|
|||
readonly id?: Endpoint14_2Request["payload"]["id"]
|
||||
readonly title: Endpoint14_2Request["payload"]["title"]
|
||||
readonly metadata?: Endpoint14_2Request["payload"]["metadata"]
|
||||
readonly mode: Endpoint14_2Request["payload"]["mode"]
|
||||
readonly fields?: Endpoint14_2Request["payload"]["fields"]
|
||||
readonly url?: Endpoint14_2Request["payload"]["url"]
|
||||
readonly fields: Endpoint14_2Request["payload"]["fields"]
|
||||
}
|
||||
export type Endpoint14_2Output = EffectValue<ReturnType<RawClient["server.form"]["session.form.create"]>>["data"]
|
||||
export type FormCreateOperation<E = never> = (input: Endpoint14_2Input) => Effect.Effect<Endpoint14_2Output, E>
|
||||
|
|
|
|||
|
|
@ -646,21 +646,12 @@ type Endpoint14_2Input = {
|
|||
readonly id?: Endpoint14_2Request["payload"]["id"]
|
||||
readonly title: Endpoint14_2Request["payload"]["title"]
|
||||
readonly metadata?: Endpoint14_2Request["payload"]["metadata"]
|
||||
readonly mode: Endpoint14_2Request["payload"]["mode"]
|
||||
readonly fields?: Endpoint14_2Request["payload"]["fields"]
|
||||
readonly url?: Endpoint14_2Request["payload"]["url"]
|
||||
readonly fields: Endpoint14_2Request["payload"]["fields"]
|
||||
}
|
||||
const Endpoint14_2 = (raw: RawClient["server.form"]) => (input: Endpoint14_2Input) =>
|
||||
raw["session.form.create"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
id: input["id"],
|
||||
title: input["title"],
|
||||
metadata: input["metadata"],
|
||||
mode: input["mode"],
|
||||
fields: input["fields"],
|
||||
url: input["url"],
|
||||
},
|
||||
payload: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
|
|
|
|||
|
|
@ -1041,14 +1041,7 @@ export function make(options: ClientOptions) {
|
|||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
|
||||
body: {
|
||||
id: input["id"],
|
||||
title: input["title"],
|
||||
metadata: input["metadata"],
|
||||
mode: input["mode"],
|
||||
fields: input["fields"],
|
||||
url: input["url"],
|
||||
},
|
||||
body: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 409, 400, 401],
|
||||
empty: false,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -64,9 +64,7 @@ export class InvalidFormError extends Schema.TaggedErrorClass<InvalidFormError>(
|
|||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export type CreateInput =
|
||||
| (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
|
||||
| (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
|
||||
export type CreateInput = Omit<Form.Info, "id"> & { readonly id?: ID }
|
||||
|
||||
export interface ReplyInput {
|
||||
readonly id: ID
|
||||
|
|
@ -74,7 +72,7 @@ export interface ReplyInput {
|
|||
}
|
||||
|
||||
export interface ListInput {
|
||||
readonly sessionID?: Form.FormInfo["sessionID"]
|
||||
readonly sessionID?: Form.Info["sessionID"]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
|
|
@ -125,20 +123,15 @@ export const layer = Layer.effect(
|
|||
const id = input.id ?? ID.create()
|
||||
const existing = yield* Cache.getSuccess(forms, id)
|
||||
if (Option.isSome(existing)) return yield* new AlreadyExistsError({ id })
|
||||
if (input.mode === "form") {
|
||||
const invalid = validateFields(input.fields)
|
||||
if (invalid) return yield* new InvalidFormError({ message: invalid })
|
||||
}
|
||||
const base = {
|
||||
const invalid = validateFields(input.fields)
|
||||
if (invalid) return yield* new InvalidFormError({ message: invalid })
|
||||
const form: Info = {
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
title: input.title,
|
||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
||||
fields: input.fields,
|
||||
}
|
||||
const form: Info =
|
||||
input.mode === "form"
|
||||
? { ...base, mode: "form", fields: input.fields }
|
||||
: { ...base, mode: "url", url: input.url }
|
||||
const entry: Entry = {
|
||||
form,
|
||||
state: { status: "pending" },
|
||||
|
|
@ -228,15 +221,12 @@ export const locationLayer = layer
|
|||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
||||
|
||||
function validateAnswer(form: Info, answer: Answer) {
|
||||
if (form.mode === "url") {
|
||||
if (Object.keys(answer).length === 0) return
|
||||
return "URL forms must be answered with an empty answer"
|
||||
}
|
||||
const fields = new Map(form.fields.map((field) => [field.key, field]))
|
||||
const fields = new Map(form.fields.flatMap((field) => (field.type === "link" ? [] : [[field.key, field] as const])))
|
||||
for (const key of Object.keys(answer)) {
|
||||
if (!fields.has(key)) return `Unknown form field: ${key}`
|
||||
}
|
||||
for (const field of form.fields) {
|
||||
if (field.type === "link") continue
|
||||
const value = answer[field.key]
|
||||
const active = isActive(field, answer)
|
||||
if (value === undefined) {
|
||||
|
|
@ -249,7 +239,9 @@ function validateAnswer(form: Info, answer: Answer) {
|
|||
}
|
||||
}
|
||||
|
||||
function isActive(field: Form.Field, answer: Answer) {
|
||||
type InputField = Exclude<Form.Field, Form.LinkField>
|
||||
|
||||
function isActive(field: InputField, answer: Answer) {
|
||||
if (!field.when) return true
|
||||
return field.when.every((when) => matches(when, answer[when.key]))
|
||||
}
|
||||
|
|
@ -267,8 +259,9 @@ function matches(when: Form.When, value: Form.Value | undefined) {
|
|||
// are closed. Rejecting these at creation surfaces authoring mistakes to the caller instead of
|
||||
// silently never matching.
|
||||
function validateFields(fields: ReadonlyArray<Form.Field>) {
|
||||
const earlier = new Map<string, Form.Field>()
|
||||
const earlier = new Map<string, InputField>()
|
||||
for (const field of fields) {
|
||||
if (field.type === "link") continue
|
||||
if (earlier.has(field.key)) return `Duplicate form field key: ${field.key}`
|
||||
for (const when of field.when ?? []) {
|
||||
const target = earlier.get(when.key)
|
||||
|
|
@ -280,7 +273,7 @@ function validateFields(fields: ReadonlyArray<Form.Field>) {
|
|||
}
|
||||
}
|
||||
|
||||
function validateWhen(when: Form.When, target: Form.Field) {
|
||||
function validateWhen(when: Form.When, target: InputField) {
|
||||
if (target.type === "boolean") {
|
||||
if (typeof when.value !== "boolean") return "Form field condition value must be a boolean"
|
||||
return
|
||||
|
|
@ -297,7 +290,7 @@ function validateWhen(when: Form.When, target: Form.Field) {
|
|||
}
|
||||
}
|
||||
|
||||
function validateField(field: Form.Field, value: Form.Value): string | undefined {
|
||||
function validateField(field: InputField, value: Form.Value): string | undefined {
|
||||
if (field.type === "string") {
|
||||
if (typeof value !== "string") return `Expected string for form field: ${field.key}`
|
||||
if (field.required && value.length === 0) return `Missing required form field: ${field.key}`
|
||||
|
|
|
|||
|
|
@ -311,8 +311,7 @@ export const layer = Layer.effect(
|
|||
elicitationID: input.params.elicitationId,
|
||||
message: input.params.message,
|
||||
},
|
||||
mode: "url",
|
||||
url: input.params.url,
|
||||
fields: [{ type: "link", url: input.params.url }],
|
||||
})
|
||||
.pipe(
|
||||
Effect.raceFirst(waitForAbort(input.signal)),
|
||||
|
|
@ -330,7 +329,6 @@ export const layer = Layer.effect(
|
|||
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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -86,7 +86,6 @@ export const Plugin = {
|
|||
kind: "question",
|
||||
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
},
|
||||
mode: "form",
|
||||
fields: input.questions.map(
|
||||
(question, index): Form.Field => ({
|
||||
key: `q${index}`,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ const input = {
|
|||
id: formID,
|
||||
sessionID: SessionSchema.ID.make("ses_test"),
|
||||
title: "Test form",
|
||||
mode: "form",
|
||||
fields: [{ key: "name", type: "string", required: true }],
|
||||
} satisfies Form.CreateInput
|
||||
|
||||
|
|
@ -47,7 +46,6 @@ describe("Form", () => {
|
|||
const created = yield* service.create({
|
||||
sessionID: "global",
|
||||
title: "MCP input",
|
||||
mode: "form",
|
||||
fields: [{ key: "name", type: "string", required: true }],
|
||||
})
|
||||
expect(created.sessionID).toBe("global")
|
||||
|
|
@ -59,6 +57,14 @@ describe("Form", () => {
|
|||
|
||||
yield* service.reply({ id: created.id, answer: { name: "Ava" } })
|
||||
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { name: "Ava" } })
|
||||
|
||||
const linkOnly = yield* service.create({
|
||||
sessionID: "global",
|
||||
title: "External setup",
|
||||
fields: [{ type: "link", url: "https://example.com/setup" }],
|
||||
})
|
||||
yield* service.reply({ id: linkOnly.id, answer: {} })
|
||||
expect(yield* service.state(linkOnly.id)).toEqual({ status: "answered", answer: {} })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -68,15 +74,18 @@ describe("Form", () => {
|
|||
const created = yield* service.create({
|
||||
sessionID: "global",
|
||||
title: "Conditional form",
|
||||
mode: "form",
|
||||
fields: [
|
||||
{ key: "confirm", type: "boolean", required: true },
|
||||
{ key: "reason", type: "string", required: true, when: [{ key: "confirm", op: "eq", value: false }] },
|
||||
],
|
||||
})
|
||||
|
||||
const inactive = yield* service.reply({ id: created.id, answer: { confirm: true, reason: "x" } }).pipe(Effect.flip)
|
||||
expect(inactive).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: reason" }))
|
||||
const inactive = yield* service
|
||||
.reply({ id: created.id, answer: { confirm: true, reason: "x" } })
|
||||
.pipe(Effect.flip)
|
||||
expect(inactive).toEqual(
|
||||
new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: reason" }),
|
||||
)
|
||||
|
||||
const missing = yield* service.reply({ id: created.id, answer: { confirm: false } }).pipe(Effect.flip)
|
||||
expect(missing).toEqual(
|
||||
|
|
@ -101,7 +110,6 @@ describe("Form", () => {
|
|||
const created = yield* service.create({
|
||||
sessionID: "global",
|
||||
title: "Multiselect form",
|
||||
mode: "form",
|
||||
fields: [
|
||||
{ key: "langs", type: "multiselect", options },
|
||||
{ key: "goVersion", type: "string", required: true, when: [{ key: "langs", op: "eq", value: "go" }] },
|
||||
|
|
@ -124,7 +132,6 @@ describe("Form", () => {
|
|||
const created = yield* service.create({
|
||||
sessionID: "global",
|
||||
title: "Dependent form",
|
||||
mode: "form",
|
||||
fields: [
|
||||
{ key: "a", type: "boolean" },
|
||||
{ key: "b", type: "boolean" },
|
||||
|
|
@ -142,7 +149,9 @@ describe("Form", () => {
|
|||
})
|
||||
|
||||
const missingX = yield* service.reply({ id: created.id, answer: { a: true, b: true, z: "ok" } }).pipe(Effect.flip)
|
||||
expect(missingX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: x" }))
|
||||
expect(missingX).toEqual(
|
||||
new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: x" }),
|
||||
)
|
||||
|
||||
const inactiveX = yield* service
|
||||
.reply({ id: created.id, answer: { a: true, b: false, x: "nope", z: "ok" } })
|
||||
|
|
@ -150,7 +159,9 @@ describe("Form", () => {
|
|||
expect(inactiveX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: x" }))
|
||||
|
||||
const missingZ = yield* service.reply({ id: created.id, answer: { a: true, b: false } }).pipe(Effect.flip)
|
||||
expect(missingZ).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: z" }))
|
||||
expect(missingZ).toEqual(
|
||||
new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: z" }),
|
||||
)
|
||||
|
||||
yield* service.reply({ id: created.id, answer: { a: true, b: false, z: "ok" } })
|
||||
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { a: true, b: false, z: "ok" } })
|
||||
|
|
@ -167,7 +178,6 @@ describe("Form", () => {
|
|||
const created = yield* service.create({
|
||||
sessionID: "global",
|
||||
title: "Selection form",
|
||||
mode: "form",
|
||||
fields: [
|
||||
{ key: "langs", type: "multiselect", options },
|
||||
{ key: "note", type: "string", required: true, when: [{ key: "langs", op: "neq", value: "go" }] },
|
||||
|
|
@ -186,7 +196,9 @@ describe("Form", () => {
|
|||
)
|
||||
|
||||
const inactive = yield* service.reply({ id: created.id, answer: { langs: ["go"], note: "x" } }).pipe(Effect.flip)
|
||||
expect(inactive).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: note" }))
|
||||
expect(inactive).toEqual(
|
||||
new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: note" }),
|
||||
)
|
||||
|
||||
yield* service.reply({ id: created.id, answer: { langs: ["go"] } })
|
||||
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { langs: ["go"] } })
|
||||
|
|
@ -199,7 +211,6 @@ describe("Form", () => {
|
|||
const created = yield* service.create({
|
||||
sessionID: "global",
|
||||
title: "Cascading form",
|
||||
mode: "form",
|
||||
fields: [
|
||||
{ key: "a", type: "boolean" },
|
||||
{ key: "b", type: "string", when: [{ key: "a", op: "eq", value: true }] },
|
||||
|
|
@ -221,13 +232,13 @@ describe("Form", () => {
|
|||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
const flipCreate = (fields: ReadonlyArray<Form.Field>) =>
|
||||
service.create({ sessionID: "global", title: "Invalid form", mode: "form", fields }).pipe(Effect.flip)
|
||||
service.create({ sessionID: "global", title: "Invalid form", fields }).pipe(Effect.flip)
|
||||
|
||||
expect(
|
||||
yield* flipCreate([
|
||||
{ key: "b", type: "string", when: [{ key: "missing", op: "eq", value: "x" }] },
|
||||
]),
|
||||
).toEqual(new Form.InvalidFormError({ message: "Form field condition must reference an earlier field: b -> missing" }))
|
||||
yield* flipCreate([{ key: "b", type: "string", when: [{ key: "missing", op: "eq", value: "x" }] }]),
|
||||
).toEqual(
|
||||
new Form.InvalidFormError({ message: "Form field condition must reference an earlier field: b -> missing" }),
|
||||
)
|
||||
|
||||
expect(
|
||||
yield* flipCreate([
|
||||
|
|
@ -241,9 +252,7 @@ describe("Form", () => {
|
|||
{ key: "a", type: "boolean" },
|
||||
{ key: "b", type: "string", when: [{ key: "a", op: "eq", value: "yes" }] },
|
||||
]),
|
||||
).toEqual(
|
||||
new Form.InvalidFormError({ message: "Form field condition value must be a boolean: b -> a" }),
|
||||
)
|
||||
).toEqual(new Form.InvalidFormError({ message: "Form field condition value must be a boolean: b -> a" }))
|
||||
|
||||
expect(
|
||||
yield* flipCreate([
|
||||
|
|
@ -258,6 +267,28 @@ describe("Form", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("treats link fields as non-answerable", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
const created = yield* service.create({
|
||||
sessionID: "global",
|
||||
title: "External setup",
|
||||
fields: [
|
||||
{ type: "link", url: "https://example.com/setup", title: "Open setup" },
|
||||
{ key: "name", type: "string", required: true },
|
||||
],
|
||||
})
|
||||
|
||||
const invalid = yield* service
|
||||
.reply({ id: created.id, answer: { link: "opened", name: "Ava" } })
|
||||
.pipe(Effect.flip)
|
||||
expect(invalid).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Unknown form field: link" }))
|
||||
|
||||
yield* service.reply({ id: created.id, answer: { name: "Ava" } })
|
||||
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { name: "Ava" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("cleans up created forms when event publication fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
|
|
|
|||
|
|
@ -158,7 +158,6 @@ describe("QuestionTool", () => {
|
|||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
||||
mode: "form",
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
|
|
@ -205,7 +204,6 @@ describe("QuestionTool", () => {
|
|||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
||||
mode: "form",
|
||||
fields: [],
|
||||
})
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -14,11 +14,9 @@ import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
|||
|
||||
const CreatePayload = Schema.Struct({
|
||||
id: Form.ID.pipe(Schema.optional),
|
||||
title: Form.FormInfo.fields.title,
|
||||
metadata: Form.FormInfo.fields.metadata,
|
||||
mode: Schema.Literals(["form", "url"]),
|
||||
fields: Form.FormInfo.fields.fields.pipe(Schema.optional),
|
||||
url: Form.UrlInfo.fields.url.pipe(Schema.optional),
|
||||
title: Form.Info.fields.title,
|
||||
metadata: Form.Info.fields.metadata,
|
||||
fields: Form.Info.fields.fields,
|
||||
}).annotate({ identifier: "Form.CreatePayload" })
|
||||
|
||||
export type CreatePayload = typeof CreatePayload.Type
|
||||
|
|
|
|||
|
|
@ -94,10 +94,23 @@ export const MultiselectField = Schema.Struct({
|
|||
}).annotate({ identifier: "Form.MultiselectField" })
|
||||
export interface MultiselectField extends Schema.Schema.Type<typeof MultiselectField> {}
|
||||
|
||||
export const Field = Schema.Union([StringField, NumberField, IntegerField, BooleanField, MultiselectField]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type Field = StringField | NumberField | IntegerField | BooleanField | MultiselectField
|
||||
export const LinkField = Schema.Struct({
|
||||
type: Schema.Literal("link"),
|
||||
url: Schema.String,
|
||||
title: Schema.String.pipe(optional),
|
||||
description: Schema.String.pipe(optional),
|
||||
}).annotate({ identifier: "Form.LinkField" })
|
||||
export interface LinkField extends Schema.Schema.Type<typeof LinkField> {}
|
||||
|
||||
export const Field = Schema.Union([
|
||||
StringField,
|
||||
NumberField,
|
||||
IntegerField,
|
||||
BooleanField,
|
||||
MultiselectField,
|
||||
LinkField,
|
||||
]).pipe(Schema.toTaggedUnion("type"), Schema.annotate({ identifier: "Form.Field" }))
|
||||
export type Field = StringField | NumberField | IntegerField | BooleanField | MultiselectField | LinkField
|
||||
|
||||
const InfoBase = {
|
||||
id: ID,
|
||||
|
|
@ -110,22 +123,11 @@ const InfoBase = {
|
|||
metadata: Metadata.pipe(optional),
|
||||
}
|
||||
|
||||
export const FormInfo = Schema.Struct({
|
||||
export const Info = Schema.Struct({
|
||||
...InfoBase,
|
||||
mode: Schema.Literal("form"),
|
||||
fields: Schema.Array(Field),
|
||||
}).annotate({ identifier: "Form.FormInfo" })
|
||||
export interface FormInfo extends Schema.Schema.Type<typeof FormInfo> {}
|
||||
|
||||
export const UrlInfo = Schema.Struct({
|
||||
...InfoBase,
|
||||
mode: Schema.Literal("url"),
|
||||
url: Schema.String,
|
||||
}).annotate({ identifier: "Form.UrlInfo" })
|
||||
export interface UrlInfo extends Schema.Schema.Type<typeof UrlInfo> {}
|
||||
|
||||
export const Info = Schema.Union([FormInfo, UrlInfo]).pipe(Schema.toTaggedUnion("mode"))
|
||||
export type Info = FormInfo | UrlInfo
|
||||
}).annotate({ identifier: "Form.Info" })
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||
import { DateTime, Schema } from "effect"
|
||||
import { Agent } from "../src/agent.js"
|
||||
import { FileSystem } from "../src/filesystem.js"
|
||||
import { Form } from "../src/form.js"
|
||||
import { Mcp } from "../src/mcp.js"
|
||||
import { Model } from "../src/model.js"
|
||||
import { Project } from "../src/project.js"
|
||||
|
|
@ -69,6 +70,9 @@ describe("contract hygiene", () => {
|
|||
const identifiers = [
|
||||
Agent.Color,
|
||||
FileSystem.Submatch,
|
||||
Form.Field,
|
||||
Form.Info,
|
||||
Form.LinkField,
|
||||
Mcp.Resource,
|
||||
Mcp.ResourceTemplate,
|
||||
Mcp.ResourceCatalog,
|
||||
|
|
|
|||
|
|
@ -1408,7 +1408,7 @@ export type GlobalEvent = {
|
|||
id: string
|
||||
type: "form.created"
|
||||
properties: {
|
||||
form: FormFormInfo | FormUrlInfo
|
||||
form: FormInfo
|
||||
}
|
||||
}
|
||||
| {
|
||||
|
|
@ -3545,22 +3545,27 @@ export type FormMultiselectField = {
|
|||
default?: Array<string>
|
||||
}
|
||||
|
||||
export type FormFormInfo = {
|
||||
id: string
|
||||
sessionID: string
|
||||
title: string
|
||||
metadata?: FormMetadata
|
||||
mode: "form"
|
||||
fields: Array<FormStringField | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectField>
|
||||
export type FormLinkField = {
|
||||
type: "link"
|
||||
url: string
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type FormUrlInfo = {
|
||||
export type FormField =
|
||||
| FormStringField
|
||||
| FormNumberField
|
||||
| FormIntegerField
|
||||
| FormBooleanField
|
||||
| FormMultiselectField
|
||||
| FormLinkField
|
||||
|
||||
export type FormInfo = {
|
||||
id: string
|
||||
sessionID: string
|
||||
title: string
|
||||
metadata?: FormMetadata
|
||||
mode: "url"
|
||||
url: string
|
||||
fields: Array<FormField>
|
||||
}
|
||||
|
||||
export type FormValue =
|
||||
|
|
@ -5836,9 +5841,7 @@ export type FormCreatePayload = {
|
|||
id?: string
|
||||
title: string
|
||||
metadata?: FormMetadata
|
||||
mode: "form" | "url"
|
||||
fields?: Array<FormStringField | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectField>
|
||||
url?: string
|
||||
fields: Array<FormField>
|
||||
}
|
||||
|
||||
export type FormState =
|
||||
|
|
@ -6559,7 +6562,7 @@ export type FormCreated = {
|
|||
type: "form.created"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
form: FormFormInfo | FormUrlInfo
|
||||
form: FormInfo
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -7831,7 +7834,7 @@ export type EventFormCreated = {
|
|||
id: string
|
||||
type: "form.created"
|
||||
properties: {
|
||||
form: FormFormInfo | FormUrlInfo
|
||||
form: FormInfo
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -9892,33 +9895,19 @@ export type FormMultiselectFieldV2 = {
|
|||
default?: Array<string>
|
||||
}
|
||||
|
||||
export type FormFormInfoV2 = {
|
||||
export type FormInfoV2 = {
|
||||
id: string
|
||||
sessionID: string
|
||||
title: string
|
||||
metadata?: FormMetadata
|
||||
mode: "form"
|
||||
fields: Array<FormStringFieldV2 | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectFieldV2>
|
||||
}
|
||||
|
||||
export type FormUrlInfoV2 = {
|
||||
id: string
|
||||
sessionID: string
|
||||
title: string
|
||||
metadata?: FormMetadata
|
||||
mode: "url"
|
||||
url: string
|
||||
fields: Array<FormField>
|
||||
}
|
||||
|
||||
export type FormCreatePayloadV2 = {
|
||||
id?: string | null
|
||||
title: string
|
||||
metadata?: FormMetadata
|
||||
mode: "form" | "url"
|
||||
fields?: Array<
|
||||
FormStringFieldV2 | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectFieldV2
|
||||
> | null
|
||||
url?: string | null
|
||||
fields: Array<FormField>
|
||||
}
|
||||
|
||||
export type FormValueV2 =
|
||||
|
|
@ -10627,22 +10616,20 @@ export type FormMultiselectField1 = {
|
|||
default?: Array<string>
|
||||
}
|
||||
|
||||
export type FormFormInfo1 = {
|
||||
id: string
|
||||
sessionID: string
|
||||
title: string
|
||||
metadata?: FormMetadata1
|
||||
mode: "form"
|
||||
fields: Array<FormStringField1 | FormNumberField1 | FormIntegerField1 | FormBooleanField1 | FormMultiselectField1>
|
||||
}
|
||||
export type FormField1 =
|
||||
| FormStringField1
|
||||
| FormNumberField1
|
||||
| FormIntegerField1
|
||||
| FormBooleanField1
|
||||
| FormMultiselectField1
|
||||
| FormLinkField
|
||||
|
||||
export type FormUrlInfo1 = {
|
||||
export type FormInfo1 = {
|
||||
id: string
|
||||
sessionID: string
|
||||
title: string
|
||||
metadata?: FormMetadata1
|
||||
mode: "url"
|
||||
url: string
|
||||
fields: Array<FormField1>
|
||||
}
|
||||
|
||||
export type FormCreatedV2 = {
|
||||
|
|
@ -10654,7 +10641,7 @@ export type FormCreatedV2 = {
|
|||
type: "form.created"
|
||||
location?: LocationRefV2
|
||||
data: {
|
||||
form: FormFormInfo1 | FormUrlInfo1
|
||||
form: FormInfo1
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -17207,7 +17194,7 @@ export type V2FormRequestListResponses = {
|
|||
*/
|
||||
200: {
|
||||
location: LocationInfoV2
|
||||
data: Array<FormFormInfoV2 | FormUrlInfoV2>
|
||||
data: Array<FormInfoV2>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -17244,7 +17231,7 @@ export type V2SessionFormListResponses = {
|
|||
* Success
|
||||
*/
|
||||
200: {
|
||||
data: Array<FormFormInfoV2 | FormUrlInfoV2>
|
||||
data: Array<FormInfoV2>
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -17285,7 +17272,7 @@ export type V2SessionFormCreateResponses = {
|
|||
* Success
|
||||
*/
|
||||
200: {
|
||||
data: FormFormInfoV2 | FormUrlInfoV2
|
||||
data: FormInfoV2
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -17323,7 +17310,7 @@ export type V2SessionFormGetResponses = {
|
|||
* Success
|
||||
*/
|
||||
200: {
|
||||
data: FormFormInfoV2 | FormUrlInfoV2
|
||||
data: FormInfoV2
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,29 +44,21 @@ export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =
|
|||
"session.form.create",
|
||||
Effect.fn(function* (ctx) {
|
||||
const form = yield* Form.Service
|
||||
const common = {
|
||||
id: ctx.payload.id,
|
||||
sessionID: ctx.params.sessionID,
|
||||
title: ctx.payload.title,
|
||||
metadata: ctx.payload.metadata,
|
||||
}
|
||||
const input = yield* (() => {
|
||||
if (ctx.payload.mode === "form") {
|
||||
if (!ctx.payload.fields) {
|
||||
return new InvalidRequestError({ message: "Form fields are required", field: "fields" })
|
||||
}
|
||||
return Effect.succeed({ ...common, mode: "form" as const, fields: ctx.payload.fields })
|
||||
}
|
||||
if (!ctx.payload.url) return new InvalidRequestError({ message: "Form URL is required", field: "url" })
|
||||
return Effect.succeed({ ...common, mode: "url" as const, url: ctx.payload.url })
|
||||
})()
|
||||
|
||||
const created = yield* form.create(input).pipe(
|
||||
Effect.catchTags({
|
||||
"Form.AlreadyExistsError": (error) => new ConflictError({ resource: error.id, message: error.message }),
|
||||
"Form.InvalidFormError": (error) => new InvalidRequestError({ message: error.message, field: "fields" }),
|
||||
}),
|
||||
)
|
||||
const created = yield* form
|
||||
.create({
|
||||
id: ctx.payload.id,
|
||||
sessionID: ctx.params.sessionID,
|
||||
title: ctx.payload.title,
|
||||
metadata: ctx.payload.metadata,
|
||||
fields: ctx.payload.fields,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
"Form.AlreadyExistsError": (error) => new ConflictError({ resource: error.id, message: error.message }),
|
||||
"Form.InvalidFormError": (error) =>
|
||||
new InvalidRequestError({ message: error.message, field: "fields" }),
|
||||
}),
|
||||
)
|
||||
return { data: created }
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@
|
|||
import type {
|
||||
AgentInfo,
|
||||
CommandInfo,
|
||||
FormFormInfo,
|
||||
FormUrlInfo,
|
||||
IntegrationInfo,
|
||||
LocationRef,
|
||||
McpServer,
|
||||
|
|
@ -38,7 +36,7 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
|||
// Global MCP elicitations temporarily use "global" instead of a real session ID, so the
|
||||
// server cannot recover their Location when settling them. Preserve the event Location
|
||||
// until MCP elicitations carry session ownership.
|
||||
export type FormInfo = (FormFormInfo | FormUrlInfo) & { readonly location?: LocationRef }
|
||||
export type FormInfo = import("@opencode-ai/sdk/v2").FormInfo & { readonly location?: LocationRef }
|
||||
|
||||
type LocationData = {
|
||||
agent?: AgentInfo[]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
|||
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
|
||||
import open from "open"
|
||||
import { selectedForeground, tint, useTheme } from "../../context/theme"
|
||||
import type { FormFormInfo, FormValue } from "@opencode-ai/sdk/v2"
|
||||
import type { FormField, FormValue } from "@opencode-ai/sdk/v2"
|
||||
import type { FormInfo } from "../../context/data"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
|
|
@ -13,7 +13,16 @@ import { useBindings, useOpencodeModeStack } from "../../keymap"
|
|||
|
||||
const FORM_MODE = "form"
|
||||
|
||||
type Field = FormFormInfo["fields"][number]
|
||||
type Field = Exclude<FormField, { type: "link" }>
|
||||
type LinkField = Extract<FormField, { type: "link" }>
|
||||
|
||||
function isField(field: FormField): field is Field {
|
||||
return field.type !== "link"
|
||||
}
|
||||
|
||||
function isLink(field: FormField): field is LinkField {
|
||||
return field.type === "link"
|
||||
}
|
||||
|
||||
function fieldLabel(field: Field) {
|
||||
return field.title ?? field.key
|
||||
|
|
@ -141,10 +150,15 @@ function requestOptions(form: FormInfo) {
|
|||
}
|
||||
|
||||
export function FormPrompt(props: { form: FormInfo }) {
|
||||
return props.form.mode === "url" ? <UrlPrompt form={props.form} /> : <FieldsPrompt form={props.form} />
|
||||
const links = () => props.form.fields.filter(isLink)
|
||||
return links().length > 0 && links().length === props.form.fields.length ? (
|
||||
<LinkPrompt form={props.form} links={links()} />
|
||||
) : (
|
||||
<FieldsPrompt form={props.form} />
|
||||
)
|
||||
}
|
||||
|
||||
function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) {
|
||||
function LinkPrompt(props: { form: FormInfo; links: LinkField[] }) {
|
||||
const sdk = useSDK()
|
||||
const { theme } = useTheme()
|
||||
const modeStack = useOpencodeModeStack()
|
||||
|
|
@ -177,7 +191,8 @@ function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) {
|
|||
desc: "Open link",
|
||||
group: "Form",
|
||||
cmd: () => {
|
||||
void open(props.form.url)
|
||||
const link = props.links[0]
|
||||
if (link) void open(link.url)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -206,7 +221,19 @@ function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) {
|
|||
<Show when={message()}>
|
||||
<text fg={theme.textMuted}>{message()}</text>
|
||||
</Show>
|
||||
<text fg={theme.secondary}>{props.form.url}</text>
|
||||
<For each={props.links}>
|
||||
{(link) => (
|
||||
<box gap={1}>
|
||||
<Show when={link.title}>
|
||||
<text fg={theme.text}>{link.title}</text>
|
||||
</Show>
|
||||
<Show when={link.description}>
|
||||
<text fg={theme.textMuted}>{link.description}</text>
|
||||
</Show>
|
||||
<text fg={theme.secondary}>{link.url}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
<box flexDirection="row" flexShrink={0} gap={2} paddingLeft={2} paddingRight={3} paddingBottom={1}>
|
||||
<text fg={theme.text}>
|
||||
|
|
@ -220,27 +247,28 @@ function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) {
|
|||
)
|
||||
}
|
||||
|
||||
function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
||||
function FieldsPrompt(props: { form: FormInfo }) {
|
||||
const sdk = useSDK()
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const modeStack = useOpencodeModeStack()
|
||||
const configuredFields = () => props.form.fields.filter(isField)
|
||||
|
||||
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
|
||||
const [store, setStore] = createStore({
|
||||
tab: 0,
|
||||
answers: Object.fromEntries(
|
||||
props.form.fields.flatMap((field) => (field.default === undefined ? [] : [[field.key, field.default]])),
|
||||
configuredFields().flatMap((field) => (field.default === undefined ? [] : [[field.key, field.default]])),
|
||||
) as Record<string, FormValue | undefined>,
|
||||
custom: Object.fromEntries(
|
||||
props.form.fields.flatMap((field) => {
|
||||
configuredFields().flatMap((field) => {
|
||||
const value = customDefault(field)
|
||||
return value === undefined ? [] : [[field.key, value]]
|
||||
}),
|
||||
) as Record<string, string>,
|
||||
selected: selectedRow(props.form.fields[0], props.form.fields[0]?.default),
|
||||
selected: selectedRow(configuredFields()[0], configuredFields()[0]?.default),
|
||||
editing: false,
|
||||
error: "",
|
||||
})
|
||||
|
|
@ -248,9 +276,10 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||
let textarea: TextareaRenderable | undefined
|
||||
let review: ScrollBoxRenderable | undefined
|
||||
|
||||
const links = createMemo(() => props.form.fields.filter(isLink))
|
||||
const fields = createMemo(() => {
|
||||
const answers: Record<string, FormValue | undefined> = {}
|
||||
return props.form.fields.filter((field) => {
|
||||
return configuredFields().filter((field) => {
|
||||
const active = (field.when ?? []).every((when) => {
|
||||
const value = answers[when.key]
|
||||
if (value === undefined) return false
|
||||
|
|
@ -263,7 +292,6 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||
})
|
||||
const single = createMemo(() => {
|
||||
const list = fields()
|
||||
if (props.form.fields.length !== 1) return false
|
||||
if (list.length !== 1) return false
|
||||
const field = list[0]!
|
||||
return field.type === "boolean" || (field.type === "string" && field.options !== undefined)
|
||||
|
|
@ -769,6 +797,26 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{props.form.title}</text>
|
||||
</box>
|
||||
<For each={links()}>
|
||||
{(link) => (
|
||||
<box
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
void open(link.url)
|
||||
}}
|
||||
>
|
||||
<Show when={link.title}>
|
||||
<text fg={theme.text}>{link.title}</text>
|
||||
</Show>
|
||||
<Show when={link.description}>
|
||||
<text fg={theme.textMuted}>{link.description}</text>
|
||||
</Show>
|
||||
<text fg={theme.secondary}>{link.url}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<Show when={!single() && !tabbed()}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
|
|
|
|||
|
|
@ -78,7 +78,6 @@ function form(id: string, sessionID = "session"): Extract<V2Event, { type: "form
|
|||
id,
|
||||
sessionID,
|
||||
title: "Input requested",
|
||||
mode: "form",
|
||||
fields: [],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1510,7 +1510,7 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
|||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/session/ses_1/form") return
|
||||
return json({
|
||||
data: [{ id: "frm_remote", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] }],
|
||||
data: [{ id: "frm_remote", sessionID: "ses_1", title: "Input requested", fields: [] }],
|
||||
})
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
|
@ -1540,13 +1540,13 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
|||
id: "evt_form_created_1",
|
||||
created: 0,
|
||||
type: "form.created",
|
||||
data: { form: { id: "frm_1", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] } },
|
||||
data: { form: { id: "frm_1", sessionID: "ses_1", title: "Input requested", fields: [] } },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_form_created_duplicate",
|
||||
created: 1,
|
||||
type: "form.created",
|
||||
data: { form: { id: "frm_1", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] } },
|
||||
data: { form: { id: "frm_1", sessionID: "ses_1", title: "Input requested", fields: [] } },
|
||||
})
|
||||
await wait(() => data.session.form.list("ses_1")?.length === 1)
|
||||
|
||||
|
|
@ -1562,7 +1562,7 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
|||
id: "evt_form_created_2",
|
||||
created: 3,
|
||||
type: "form.created",
|
||||
data: { form: { id: "frm_2", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] } },
|
||||
data: { form: { id: "frm_2", sessionID: "ses_1", title: "Input requested", fields: [] } },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_form_cancelled_2",
|
||||
|
|
@ -1612,7 +1612,7 @@ test("tracks global forms by location", async () => {
|
|||
location: other,
|
||||
type: "form.created",
|
||||
data: {
|
||||
form: { id: "frm_other", sessionID: "global", title: "Input requested", mode: "form", fields: [] },
|
||||
form: { id: "frm_other", sessionID: "global", title: "Input requested", fields: [] },
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -1625,7 +1625,7 @@ test("tracks global forms by location", async () => {
|
|||
location: { directory },
|
||||
type: "form.created",
|
||||
data: {
|
||||
form: { id: "frm_default", sessionID: "global", title: "Input requested", mode: "form", fields: [] },
|
||||
form: { id: "frm_default", sessionID: "global", title: "Input requested", fields: [] },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.form.list("global", { directory })?.length === 1)
|
||||
|
|
@ -1664,7 +1664,6 @@ test("refreshes global forms for the requested location", async () => {
|
|||
id: requestedDirectory === other.directory ? "frm_other" : "frm_default",
|
||||
sessionID: "global",
|
||||
title: "Input requested",
|
||||
mode: "form",
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
|
|
@ -1743,7 +1742,6 @@ test("refreshes global forms once per loaded location after reconnect", async ()
|
|||
id: `frm_${requestedDirectory === other.directory ? "other" : "default"}_${count}`,
|
||||
sessionID: "global",
|
||||
title: "Input requested",
|
||||
mode: "form",
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
|
|
@ -1803,13 +1801,12 @@ test("refreshes global forms once per loaded location after reconnect", async ()
|
|||
test("reconciles all pending form requests when the event stream reconnects", async () => {
|
||||
const events = createEventStream()
|
||||
let requests = [
|
||||
{ id: "frm_old", sessionID: "ses_old", title: "Input requested", mode: "form" as const, fields: [] },
|
||||
{ id: "frm_old", sessionID: "ses_old", title: "Input requested", fields: [] },
|
||||
{
|
||||
id: "frm_keep",
|
||||
sessionID: "ses_keep",
|
||||
title: "Input requested",
|
||||
mode: "url" as const,
|
||||
url: "https://example.com",
|
||||
fields: [{ type: "link" as const, url: "https://example.com" }],
|
||||
},
|
||||
]
|
||||
let calls = 0
|
||||
|
|
@ -1841,7 +1838,7 @@ test("reconciles all pending form requests when the event stream reconnects", as
|
|||
await wait(() => data.session.form.list("ses_old")?.[0]?.id === "frm_old")
|
||||
expect(data.session.form.list("ses_keep")?.[0]?.id).toBe("frm_keep")
|
||||
|
||||
requests = [{ id: "frm_new", sessionID: "ses_new", title: "Input requested", mode: "form" as const, fields: [] }]
|
||||
requests = [{ id: "frm_new", sessionID: "ses_new", title: "Input requested", fields: [] }]
|
||||
events.disconnect()
|
||||
|
||||
await wait(() => calls === 2 && data.session.form.list("ses_new")?.[0]?.id === "frm_new")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue