refactor(form): model links as fields (#36129)
This commit is contained in:
@@ -550,9 +550,7 @@ export type Endpoint14_2Input = {
|
|||||||
readonly id?: Endpoint14_2Request["payload"]["id"]
|
readonly id?: Endpoint14_2Request["payload"]["id"]
|
||||||
readonly title: Endpoint14_2Request["payload"]["title"]
|
readonly title: Endpoint14_2Request["payload"]["title"]
|
||||||
readonly metadata?: Endpoint14_2Request["payload"]["metadata"]
|
readonly metadata?: Endpoint14_2Request["payload"]["metadata"]
|
||||||
readonly mode: Endpoint14_2Request["payload"]["mode"]
|
readonly fields: Endpoint14_2Request["payload"]["fields"]
|
||||||
readonly fields?: Endpoint14_2Request["payload"]["fields"]
|
|
||||||
readonly url?: Endpoint14_2Request["payload"]["url"]
|
|
||||||
}
|
}
|
||||||
export type Endpoint14_2Output = EffectValue<ReturnType<RawClient["server.form"]["session.form.create"]>>["data"]
|
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>
|
export type FormCreateOperation<E = never> = (input: Endpoint14_2Input) => Effect.Effect<Endpoint14_2Output, E>
|
||||||
|
|||||||
@@ -655,21 +655,12 @@ type Endpoint14_2Input = {
|
|||||||
readonly id?: Endpoint14_2Request["payload"]["id"]
|
readonly id?: Endpoint14_2Request["payload"]["id"]
|
||||||
readonly title: Endpoint14_2Request["payload"]["title"]
|
readonly title: Endpoint14_2Request["payload"]["title"]
|
||||||
readonly metadata?: Endpoint14_2Request["payload"]["metadata"]
|
readonly metadata?: Endpoint14_2Request["payload"]["metadata"]
|
||||||
readonly mode: Endpoint14_2Request["payload"]["mode"]
|
readonly fields: Endpoint14_2Request["payload"]["fields"]
|
||||||
readonly fields?: Endpoint14_2Request["payload"]["fields"]
|
|
||||||
readonly url?: Endpoint14_2Request["payload"]["url"]
|
|
||||||
}
|
}
|
||||||
const Endpoint14_2 = (raw: RawClient["server.form"]) => (input: Endpoint14_2Input) =>
|
const Endpoint14_2 = (raw: RawClient["server.form"]) => (input: Endpoint14_2Input) =>
|
||||||
raw["session.form.create"]({
|
raw["session.form.create"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: {
|
payload: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
|
||||||
id: input["id"],
|
|
||||||
title: input["title"],
|
|
||||||
metadata: input["metadata"],
|
|
||||||
mode: input["mode"],
|
|
||||||
fields: input["fields"],
|
|
||||||
url: input["url"],
|
|
||||||
},
|
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
|
|||||||
@@ -1056,14 +1056,7 @@ export function make(options: ClientOptions) {
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
|
||||||
body: {
|
body: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
|
||||||
id: input["id"],
|
|
||||||
title: input["title"],
|
|
||||||
metadata: input["metadata"],
|
|
||||||
mode: input["mode"],
|
|
||||||
fields: input["fields"],
|
|
||||||
url: input["url"],
|
|
||||||
},
|
|
||||||
successStatus: 200,
|
successStatus: 200,
|
||||||
declaredStatuses: [404, 409, 400, 401],
|
declaredStatuses: [404, 409, 400, 401],
|
||||||
empty: false,
|
empty: false,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+23
-21
@@ -16,6 +16,9 @@ export type Info = typeof Info.Type
|
|||||||
export const Field = Form.Field
|
export const Field = Form.Field
|
||||||
export type Field = Form.Field
|
export type Field = Form.Field
|
||||||
|
|
||||||
|
export const Fields = Form.Fields
|
||||||
|
export type Fields = Form.Fields
|
||||||
|
|
||||||
export const When = Form.When
|
export const When = Form.When
|
||||||
export type When = Form.When
|
export type When = Form.When
|
||||||
|
|
||||||
@@ -64,9 +67,7 @@ export class InvalidFormError extends Schema.TaggedErrorClass<InvalidFormError>(
|
|||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export type CreateInput =
|
export type CreateInput = Omit<Form.Info, "id"> & { readonly id?: ID }
|
||||||
| (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
|
|
||||||
| (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
|
|
||||||
|
|
||||||
export interface ReplyInput {
|
export interface ReplyInput {
|
||||||
readonly id: ID
|
readonly id: ID
|
||||||
@@ -74,7 +75,7 @@ export interface ReplyInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ListInput {
|
export interface ListInput {
|
||||||
readonly sessionID?: Form.FormInfo["sessionID"]
|
readonly sessionID?: Form.Info["sessionID"]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
@@ -125,20 +126,15 @@ export const layer = Layer.effect(
|
|||||||
const id = input.id ?? ID.create()
|
const id = input.id ?? ID.create()
|
||||||
const existing = yield* Cache.getSuccess(forms, id)
|
const existing = yield* Cache.getSuccess(forms, id)
|
||||||
if (Option.isSome(existing)) return yield* new AlreadyExistsError({ id })
|
if (Option.isSome(existing)) return yield* new AlreadyExistsError({ id })
|
||||||
if (input.mode === "form") {
|
|
||||||
const invalid = validateFields(input.fields)
|
const invalid = validateFields(input.fields)
|
||||||
if (invalid) return yield* new InvalidFormError({ message: invalid })
|
if (invalid) return yield* new InvalidFormError({ message: invalid })
|
||||||
}
|
const form: Info = {
|
||||||
const base = {
|
|
||||||
id,
|
id,
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
...(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 = {
|
const entry: Entry = {
|
||||||
form,
|
form,
|
||||||
state: { status: "pending" },
|
state: { status: "pending" },
|
||||||
@@ -228,16 +224,16 @@ export const locationLayer = layer
|
|||||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
||||||
|
|
||||||
function validateAnswer(form: Info, answer: Answer) {
|
function validateAnswer(form: Info, answer: Answer) {
|
||||||
if (form.mode === "url") {
|
const fields = new Map(form.fields.map((field) => [field.key, field] as const))
|
||||||
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]))
|
|
||||||
for (const key of Object.keys(answer)) {
|
for (const key of Object.keys(answer)) {
|
||||||
if (!fields.has(key)) return `Unknown form field: ${key}`
|
if (!fields.has(key)) return `Unknown form field: ${key}`
|
||||||
}
|
}
|
||||||
for (const field of form.fields) {
|
for (const field of form.fields) {
|
||||||
const value = answer[field.key]
|
const value = answer[field.key]
|
||||||
|
if (field.type === "external") {
|
||||||
|
if (value !== true) return `External form field must be acknowledged: ${field.key}`
|
||||||
|
continue
|
||||||
|
}
|
||||||
const active = isActive(field, answer)
|
const active = isActive(field, answer)
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
if (field.required && active) return `Missing required form field: ${field.key}`
|
if (field.required && active) return `Missing required form field: ${field.key}`
|
||||||
@@ -249,7 +245,9 @@ function validateAnswer(form: Info, answer: Answer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActive(field: Form.Field, answer: Answer) {
|
type InputField = Exclude<Form.Field, Form.ExternalField>
|
||||||
|
|
||||||
|
function isActive(field: InputField, answer: Answer) {
|
||||||
if (!field.when) return true
|
if (!field.when) return true
|
||||||
return field.when.every((when) => matches(when, answer[when.key]))
|
return field.when.every((when) => matches(when, answer[when.key]))
|
||||||
}
|
}
|
||||||
@@ -267,9 +265,13 @@ function matches(when: Form.When, value: Form.Value | undefined) {
|
|||||||
// are closed. Rejecting these at creation surfaces authoring mistakes to the caller instead of
|
// are closed. Rejecting these at creation surfaces authoring mistakes to the caller instead of
|
||||||
// silently never matching.
|
// silently never matching.
|
||||||
function validateFields(fields: ReadonlyArray<Form.Field>) {
|
function validateFields(fields: ReadonlyArray<Form.Field>) {
|
||||||
const earlier = new Map<string, Form.Field>()
|
if (fields.length === 0) return "Form must have at least one field"
|
||||||
|
const earlier = new Map<string, InputField>()
|
||||||
|
const keys = new Set<string>()
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
if (earlier.has(field.key)) return `Duplicate form field key: ${field.key}`
|
if (keys.has(field.key)) return `Duplicate form field key: ${field.key}`
|
||||||
|
keys.add(field.key)
|
||||||
|
if (field.type === "external") continue
|
||||||
for (const when of field.when ?? []) {
|
for (const when of field.when ?? []) {
|
||||||
const target = earlier.get(when.key)
|
const target = earlier.get(when.key)
|
||||||
if (!target) return `Form field condition must reference an earlier field: ${field.key} -> ${when.key}`
|
if (!target) return `Form field condition must reference an earlier field: ${field.key} -> ${when.key}`
|
||||||
@@ -280,7 +282,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 (target.type === "boolean") {
|
||||||
if (typeof when.value !== "boolean") return "Form field condition value must be a boolean"
|
if (typeof when.value !== "boolean") return "Form field condition value must be a boolean"
|
||||||
return
|
return
|
||||||
@@ -297,7 +299,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 (field.type === "string") {
|
||||||
if (typeof value !== "string") return `Expected string for form field: ${field.key}`
|
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}`
|
if (field.required && value.length === 0) return `Missing required form field: ${field.key}`
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ type ServerEntry = {
|
|||||||
// MCP elicitations are Location-scoped, not Session-scoped: the server cannot attribute them to a
|
// MCP elicitations are Location-scoped, not Session-scoped: the server cannot attribute them to a
|
||||||
// persisted session row, so their forms are owned by this opaque sentinel session identifier.
|
// persisted session row, so their forms are owned by this opaque sentinel session identifier.
|
||||||
const GLOBAL_ELICITATION_SESSION_ID = "global"
|
const GLOBAL_ELICITATION_SESSION_ID = "global"
|
||||||
|
const URL_ELICITATION_FIELD_KEY = "elicitation"
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly servers: () => Effect.Effect<ServerInfo[]>
|
readonly servers: () => Effect.Effect<ServerInfo[]>
|
||||||
@@ -311,8 +312,7 @@ export const layer = Layer.effect(
|
|||||||
elicitationID: input.params.elicitationId,
|
elicitationID: input.params.elicitationId,
|
||||||
message: input.params.message,
|
message: input.params.message,
|
||||||
},
|
},
|
||||||
mode: "url",
|
fields: [{ key: URL_ELICITATION_FIELD_KEY, type: "external", url: input.params.url }],
|
||||||
url: input.params.url,
|
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.raceFirst(waitForAbort(input.signal)),
|
Effect.raceFirst(waitForAbort(input.signal)),
|
||||||
@@ -325,15 +325,16 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
const params = input.params
|
const params = input.params
|
||||||
|
const [field, ...fields] = Object.entries(params.requestedSchema.properties).map(([key, property]) =>
|
||||||
|
toElicitationField(key, property, params.requestedSchema.required?.includes(key) === true),
|
||||||
|
)
|
||||||
|
if (!field) return { action: "accept", content: {} }
|
||||||
return yield* forms
|
return yield* forms
|
||||||
.ask({
|
.ask({
|
||||||
sessionID: GLOBAL_ELICITATION_SESSION_ID,
|
sessionID: GLOBAL_ELICITATION_SESSION_ID,
|
||||||
title: `${input.server} is requesting input`,
|
title: `${input.server} is requesting input`,
|
||||||
metadata: { kind: "mcp-elicitation", server: input.server, message: params.message },
|
metadata: { kind: "mcp-elicitation", server: input.server, message: params.message },
|
||||||
mode: "form",
|
fields: [field, ...fields],
|
||||||
fields: Object.entries(params.requestedSchema.properties).map(([key, property]) =>
|
|
||||||
toElicitationField(key, property, params.requestedSchema.required?.includes(key) === true),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.raceFirst(waitForAbort(input.signal)),
|
Effect.raceFirst(waitForAbort(input.signal)),
|
||||||
@@ -355,7 +356,7 @@ export const layer = Layer.effect(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const formID = urlElicitations.get(input.server + "\u0000" + input.elicitationID)
|
const formID = urlElicitations.get(input.server + "\u0000" + input.elicitationID)
|
||||||
if (!formID) return
|
if (!formID) return
|
||||||
yield* forms.reply({ id: formID, answer: {} }).pipe(Effect.ignore)
|
yield* forms.reply({ id: formID, answer: { [URL_ELICITATION_FIELD_KEY]: true } }).pipe(Effect.ignore)
|
||||||
}),
|
}),
|
||||||
} satisfies MCPClient.ElicitationHandler
|
} satisfies MCPClient.ElicitationHandler
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Usage notes:
|
|||||||
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
|
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
|
||||||
|
|
||||||
export const Input = Schema.Struct({
|
export const Input = Schema.Struct({
|
||||||
questions: Schema.Array(QuestionV2.Prompt).annotate({ description: "Questions to ask" }),
|
questions: Schema.NonEmptyArray(QuestionV2.Prompt).annotate({ description: "Questions to ask" }),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Output = Schema.Struct({
|
export const Output = Schema.Struct({
|
||||||
@@ -86,21 +86,10 @@ export const Plugin = {
|
|||||||
kind: "question",
|
kind: "question",
|
||||||
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
|
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||||
},
|
},
|
||||||
mode: "form",
|
fields: [
|
||||||
fields: input.questions.map(
|
toField(input.questions[0], 0),
|
||||||
(question, index): Form.Field => ({
|
...input.questions.slice(1).map((question, index) => toField(question, index + 1)),
|
||||||
key: `q${index}`,
|
],
|
||||||
title: question.header,
|
|
||||||
description: question.question,
|
|
||||||
type: question.multiple === true ? "multiselect" : "string",
|
|
||||||
options: question.options.map((option) => ({
|
|
||||||
value: option.label,
|
|
||||||
label: option.label,
|
|
||||||
description: option.description,
|
|
||||||
})),
|
|
||||||
custom: true,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
.pipe(Effect.orDie),
|
.pipe(Effect.orDie),
|
||||||
),
|
),
|
||||||
@@ -122,3 +111,18 @@ export const Plugin = {
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toField(question: QuestionV2.Prompt, index: number): Form.Field {
|
||||||
|
return {
|
||||||
|
key: `q${index}`,
|
||||||
|
title: question.header,
|
||||||
|
description: question.question,
|
||||||
|
type: question.multiple === true ? "multiselect" : "string",
|
||||||
|
options: question.options.map((option) => ({
|
||||||
|
value: option.label,
|
||||||
|
label: option.label,
|
||||||
|
description: option.description,
|
||||||
|
})),
|
||||||
|
custom: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ const input = {
|
|||||||
id: formID,
|
id: formID,
|
||||||
sessionID: SessionSchema.ID.make("ses_test"),
|
sessionID: SessionSchema.ID.make("ses_test"),
|
||||||
title: "Test form",
|
title: "Test form",
|
||||||
mode: "form",
|
|
||||||
fields: [{ key: "name", type: "string", required: true }],
|
fields: [{ key: "name", type: "string", required: true }],
|
||||||
} satisfies Form.CreateInput
|
} satisfies Form.CreateInput
|
||||||
|
|
||||||
@@ -47,7 +46,6 @@ describe("Form", () => {
|
|||||||
const created = yield* service.create({
|
const created = yield* service.create({
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
title: "MCP input",
|
title: "MCP input",
|
||||||
mode: "form",
|
|
||||||
fields: [{ key: "name", type: "string", required: true }],
|
fields: [{ key: "name", type: "string", required: true }],
|
||||||
})
|
})
|
||||||
expect(created.sessionID).toBe("global")
|
expect(created.sessionID).toBe("global")
|
||||||
@@ -59,6 +57,14 @@ describe("Form", () => {
|
|||||||
|
|
||||||
yield* service.reply({ id: created.id, answer: { name: "Ava" } })
|
yield* service.reply({ id: created.id, answer: { name: "Ava" } })
|
||||||
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { name: "Ava" } })
|
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { name: "Ava" } })
|
||||||
|
|
||||||
|
const externalOnly = yield* service.create({
|
||||||
|
sessionID: "global",
|
||||||
|
title: "External setup",
|
||||||
|
fields: [{ key: "setup", type: "external", url: "https://example.com/setup" }],
|
||||||
|
})
|
||||||
|
yield* service.reply({ id: externalOnly.id, answer: { setup: true } })
|
||||||
|
expect(yield* service.state(externalOnly.id)).toEqual({ status: "answered", answer: { setup: true } })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,15 +74,18 @@ describe("Form", () => {
|
|||||||
const created = yield* service.create({
|
const created = yield* service.create({
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
title: "Conditional form",
|
title: "Conditional form",
|
||||||
mode: "form",
|
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "confirm", type: "boolean", required: true },
|
{ key: "confirm", type: "boolean", required: true },
|
||||||
{ key: "reason", type: "string", required: true, when: [{ key: "confirm", op: "eq", value: false }] },
|
{ 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)
|
const inactive = yield* service
|
||||||
expect(inactive).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: reason" }))
|
.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)
|
const missing = yield* service.reply({ id: created.id, answer: { confirm: false } }).pipe(Effect.flip)
|
||||||
expect(missing).toEqual(
|
expect(missing).toEqual(
|
||||||
@@ -101,7 +110,6 @@ describe("Form", () => {
|
|||||||
const created = yield* service.create({
|
const created = yield* service.create({
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
title: "Multiselect form",
|
title: "Multiselect form",
|
||||||
mode: "form",
|
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "langs", type: "multiselect", options },
|
{ key: "langs", type: "multiselect", options },
|
||||||
{ key: "goVersion", type: "string", required: true, when: [{ key: "langs", op: "eq", value: "go" }] },
|
{ key: "goVersion", type: "string", required: true, when: [{ key: "langs", op: "eq", value: "go" }] },
|
||||||
@@ -124,7 +132,6 @@ describe("Form", () => {
|
|||||||
const created = yield* service.create({
|
const created = yield* service.create({
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
title: "Dependent form",
|
title: "Dependent form",
|
||||||
mode: "form",
|
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "a", type: "boolean" },
|
{ key: "a", type: "boolean" },
|
||||||
{ key: "b", 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)
|
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
|
const inactiveX = yield* service
|
||||||
.reply({ id: created.id, answer: { a: true, b: false, x: "nope", z: "ok" } })
|
.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" }))
|
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)
|
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" } })
|
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" } })
|
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({
|
const created = yield* service.create({
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
title: "Selection form",
|
title: "Selection form",
|
||||||
mode: "form",
|
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "langs", type: "multiselect", options },
|
{ key: "langs", type: "multiselect", options },
|
||||||
{ key: "note", type: "string", required: true, when: [{ key: "langs", op: "neq", value: "go" }] },
|
{ 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)
|
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"] } })
|
yield* service.reply({ id: created.id, answer: { langs: ["go"] } })
|
||||||
expect(yield* service.state(created.id)).toEqual({ status: "answered", 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({
|
const created = yield* service.create({
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
title: "Cascading form",
|
title: "Cascading form",
|
||||||
mode: "form",
|
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "a", type: "boolean" },
|
{ key: "a", type: "boolean" },
|
||||||
{ key: "b", type: "string", when: [{ key: "a", op: "eq", value: true }] },
|
{ key: "b", type: "string", when: [{ key: "a", op: "eq", value: true }] },
|
||||||
@@ -220,14 +231,14 @@ describe("Form", () => {
|
|||||||
it.effect("rejects invalid when definitions at creation", () =>
|
it.effect("rejects invalid when definitions at creation", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* Form.Service
|
const service = yield* Form.Service
|
||||||
const flipCreate = (fields: ReadonlyArray<Form.Field>) =>
|
const flipCreate = (fields: Form.CreateInput["fields"]) =>
|
||||||
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(
|
expect(
|
||||||
yield* flipCreate([
|
yield* flipCreate([{ key: "b", type: "string", when: [{ key: "missing", op: "eq", value: "x" }] }]),
|
||||||
{ 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" }),
|
||||||
).toEqual(new Form.InvalidFormError({ message: "Form field condition must reference an earlier field: b -> missing" }))
|
)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
yield* flipCreate([
|
yield* flipCreate([
|
||||||
@@ -236,14 +247,19 @@ describe("Form", () => {
|
|||||||
]),
|
]),
|
||||||
).toEqual(new Form.InvalidFormError({ message: "Duplicate form field key: a" }))
|
).toEqual(new Form.InvalidFormError({ message: "Duplicate form field key: a" }))
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* flipCreate([
|
||||||
|
{ key: "a", type: "external", url: "https://example.com" },
|
||||||
|
{ key: "a", type: "string" },
|
||||||
|
]),
|
||||||
|
).toEqual(new Form.InvalidFormError({ message: "Duplicate form field key: a" }))
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
yield* flipCreate([
|
yield* flipCreate([
|
||||||
{ key: "a", type: "boolean" },
|
{ key: "a", type: "boolean" },
|
||||||
{ key: "b", type: "string", when: [{ key: "a", op: "eq", value: "yes" }] },
|
{ key: "b", type: "string", when: [{ key: "a", op: "eq", value: "yes" }] },
|
||||||
]),
|
]),
|
||||||
).toEqual(
|
).toEqual(new Form.InvalidFormError({ message: "Form field condition value must be a boolean: b -> a" }))
|
||||||
new Form.InvalidFormError({ message: "Form field condition value must be a boolean: b -> a" }),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
yield* flipCreate([
|
yield* flipCreate([
|
||||||
@@ -258,6 +274,40 @@ describe("Form", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("requires external field acknowledgements", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const service = yield* Form.Service
|
||||||
|
const created = yield* service.create({
|
||||||
|
sessionID: "global",
|
||||||
|
title: "External setup",
|
||||||
|
fields: [
|
||||||
|
{ key: "authorization", type: "external", url: "https://example.com/setup", title: "Open setup" },
|
||||||
|
{ key: "name", type: "string", required: true },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const invalidAnswers: ReadonlyArray<Form.Answer> = [
|
||||||
|
{ name: "Ava" },
|
||||||
|
{ authorization: false, name: "Ava" },
|
||||||
|
{ authorization: "yes", name: "Ava" },
|
||||||
|
]
|
||||||
|
for (const answer of invalidAnswers) {
|
||||||
|
expect(yield* service.reply({ id: created.id, answer }).pipe(Effect.flip)).toEqual(
|
||||||
|
new Form.InvalidAnswerError({
|
||||||
|
id: created.id,
|
||||||
|
message: "External form field must be acknowledged: authorization",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* service.reply({ id: created.id, answer: { authorization: true, name: "Ava" } })
|
||||||
|
expect(yield* service.state(created.id)).toEqual({
|
||||||
|
status: "answered",
|
||||||
|
answer: { authorization: true, name: "Ava" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("cleans up created forms when event publication fails", () =>
|
it.effect("cleans up created forms when event publication fails", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const service = yield* Form.Service
|
const service = yield* Form.Service
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||||
import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"
|
import { Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
import { location } from "./fixture/location"
|
import { location } from "./fixture/location"
|
||||||
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||||
@@ -47,7 +47,9 @@ type ResourceTemplatePage = {
|
|||||||
nextCursor?: string
|
nextCursor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function resourceServer(input: { resources?: boolean; listChanged?: boolean } = {}) {
|
function resourceServer(
|
||||||
|
input: { resources?: boolean; listChanged?: boolean; emptyElicitation?: boolean; urlElicitation?: boolean } = {},
|
||||||
|
) {
|
||||||
return Effect.acquireRelease(
|
return Effect.acquireRelease(
|
||||||
Effect.promise(async () => {
|
Effect.promise(async () => {
|
||||||
const state = {
|
const state = {
|
||||||
@@ -71,7 +73,42 @@ function resourceServer(input: { resources?: boolean; listChanged?: boolean } =
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
|
protocol.setRequestHandler(ListToolsRequestSchema, () =>
|
||||||
|
Promise.resolve({
|
||||||
|
tools: input.emptyElicitation
|
||||||
|
? [{ name: "empty-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
|
||||||
|
: input.urlElicitation
|
||||||
|
? [{ name: "url-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
|
||||||
|
: [],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (input.emptyElicitation) {
|
||||||
|
protocol.setRequestHandler(CallToolRequestSchema, async () => {
|
||||||
|
const result = await protocol.elicitInput({
|
||||||
|
mode: "form",
|
||||||
|
message: "Confirm",
|
||||||
|
requestedSchema: { type: "object", properties: {} },
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(result) }],
|
||||||
|
structuredContent: result,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (input.urlElicitation) {
|
||||||
|
protocol.setRequestHandler(CallToolRequestSchema, async () => {
|
||||||
|
const result = await protocol.elicitInput({
|
||||||
|
mode: "url",
|
||||||
|
message: "Authorize access",
|
||||||
|
url: "https://example.com/authorize",
|
||||||
|
elicitationId: "elicitation-test",
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: JSON.stringify(result) }],
|
||||||
|
structuredContent: result,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
if (input.resources !== false) {
|
if (input.resources !== false) {
|
||||||
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
|
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
|
||||||
state.resourceLists += 1
|
state.resourceLists += 1
|
||||||
@@ -98,6 +135,7 @@ function resourceServer(input: { resources?: boolean; listChanged?: boolean } =
|
|||||||
state,
|
state,
|
||||||
url: http.url.toString(),
|
url: http.url.toString(),
|
||||||
sendResourceListChanged: () => protocol.sendResourceListChanged(),
|
sendResourceListChanged: () => protocol.sendResourceListChanged(),
|
||||||
|
completeElicitation: () => protocol.createElicitationCompletionNotifier("elicitation-test")(),
|
||||||
close: async () => {
|
close: async () => {
|
||||||
await protocol.close().catch(() => {})
|
await protocol.close().catch(() => {})
|
||||||
await http.stop(true)
|
await http.stop(true)
|
||||||
@@ -108,10 +146,11 @@ function resourceServer(input: { resources?: boolean; listChanged?: boolean } =
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resourceMcpLayer(url: string) {
|
function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effect.Effect<void>) {
|
||||||
const directory = AbsolutePath.make(import.meta.dir)
|
const directory = AbsolutePath.make(import.meta.dir)
|
||||||
const unusedIntegration = () => Effect.die("unused integration service")
|
const unusedIntegration = () => Effect.die("unused integration service")
|
||||||
return MCP.layer.pipe(
|
return MCP.layer.pipe(
|
||||||
|
Layer.provideMerge(Form.layer),
|
||||||
Layer.provide(
|
Layer.provide(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
Layer.succeed(
|
Layer.succeed(
|
||||||
@@ -133,14 +172,16 @@ function resourceMcpLayer(url: string) {
|
|||||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
|
Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
|
||||||
Layer.mock(EventV2.Service, {
|
Layer.mock(EventV2.Service, {
|
||||||
subscribe: () => Stream.never,
|
subscribe: () => Stream.never,
|
||||||
publish: (definition, data) =>
|
publish: (definition, data) => {
|
||||||
Effect.succeed({
|
const event = {
|
||||||
id: EventV2.ID.create(),
|
id: EventV2.ID.create(),
|
||||||
type: definition.type,
|
type: definition.type,
|
||||||
data,
|
data,
|
||||||
} as EventV2.Payload<typeof definition>),
|
} as EventV2.Payload<typeof definition>
|
||||||
|
if (event.type !== Form.Event.Created.type || !onFormCreated) return Effect.succeed(event)
|
||||||
|
return onFormCreated(Schema.decodeUnknownSync(Form.Event.Created.data)(data).form).pipe(Effect.as(event))
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
Layer.mock(Form.Service, {}),
|
|
||||||
Layer.mock(Integration.Service, {
|
Layer.mock(Integration.Service, {
|
||||||
connection: {
|
connection: {
|
||||||
active: unusedIntegration,
|
active: unusedIntegration,
|
||||||
@@ -490,6 +531,53 @@ test("skips MCP resource requests when the capability is absent", async () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("accepts empty MCP elicitations without creating forms", async () => {
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const server = yield* resourceServer({ resources: false, emptyElicitation: true })
|
||||||
|
const result = yield* Effect.gen(function* () {
|
||||||
|
const service = yield* MCP.Service
|
||||||
|
const forms = yield* Form.Service
|
||||||
|
const result = yield* service.callTool({ server: "resources", name: "empty-elicitation" })
|
||||||
|
expect(yield* forms.list()).toEqual([])
|
||||||
|
return result
|
||||||
|
}).pipe(Effect.provide(resourceMcpLayer(server.url)))
|
||||||
|
|
||||||
|
expect(result.structured).toEqual({ action: "accept", content: {} })
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("acknowledges completed MCP URL elicitations without returning internal content", async () => {
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const server = yield* resourceServer({ resources: false, urlElicitation: true })
|
||||||
|
const created = yield* Deferred.make<Form.Info>()
|
||||||
|
const result = yield* Effect.gen(function* () {
|
||||||
|
const service = yield* MCP.Service
|
||||||
|
const forms = yield* Form.Service
|
||||||
|
const call = yield* service.callTool({ server: "resources", name: "url-elicitation" }).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
|
const form = yield* Deferred.await(created)
|
||||||
|
expect(form.fields).toEqual([{ key: "elicitation", type: "external", url: "https://example.com/authorize" }])
|
||||||
|
|
||||||
|
yield* Effect.promise(server.completeElicitation)
|
||||||
|
const result = yield* Fiber.join(call)
|
||||||
|
expect(yield* forms.state(form.id)).toEqual({ status: "answered", answer: { elicitation: true } })
|
||||||
|
return result
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(resourceMcpLayer(server.url, (form) => Deferred.succeed(created, form).pipe(Effect.asVoid))),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.structured).toEqual({ action: "accept" })
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
test("loads and reads MCP resources", async () => {
|
test("loads and reads MCP resources", async () => {
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ let captured: Form.CreateInput | undefined
|
|||||||
let reject = false
|
let reject = false
|
||||||
let deny = false
|
let deny = false
|
||||||
const capturedInput = () => captured
|
const capturedInput = () => captured
|
||||||
|
const questionInput = {
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
question: "Continue?",
|
||||||
|
header: "Continue",
|
||||||
|
options: [{ label: "Yes", description: "Continue" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
const permission = Layer.succeed(
|
const permission = Layer.succeed(
|
||||||
PermissionV2.Service,
|
PermissionV2.Service,
|
||||||
PermissionV2.Service.of({
|
PermissionV2.Service.of({
|
||||||
@@ -90,7 +99,7 @@ describe("QuestionTool", () => {
|
|||||||
yield* settleTool(registry, {
|
yield* settleTool(registry, {
|
||||||
sessionID,
|
sessionID,
|
||||||
...toolIdentity,
|
...toolIdentity,
|
||||||
call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
|
call: { type: "tool-call", id: "call-question-denied", name: "question", input: questionInput },
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
result: { type: "error", value: "Permission denied: question" },
|
result: { type: "error", value: "Permission denied: question" },
|
||||||
@@ -158,7 +167,6 @@ describe("QuestionTool", () => {
|
|||||||
sessionID,
|
sessionID,
|
||||||
title: "Questions",
|
title: "Questions",
|
||||||
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
||||||
mode: "form",
|
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
key: "q0",
|
key: "q0",
|
||||||
@@ -199,14 +207,22 @@ describe("QuestionTool", () => {
|
|||||||
yield* executeTool(registryService, {
|
yield* executeTool(registryService, {
|
||||||
sessionID,
|
sessionID,
|
||||||
...toolIdentity,
|
...toolIdentity,
|
||||||
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
|
call: { type: "tool-call", id: "call-question", name: "question", input: questionInput },
|
||||||
})
|
})
|
||||||
expect(capturedInput()).toEqual({
|
expect(capturedInput()).toEqual({
|
||||||
sessionID,
|
sessionID,
|
||||||
title: "Questions",
|
title: "Questions",
|
||||||
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
||||||
mode: "form",
|
fields: [
|
||||||
fields: [],
|
{
|
||||||
|
key: "q0",
|
||||||
|
title: "Continue",
|
||||||
|
description: "Continue?",
|
||||||
|
options: [{ value: "Yes", label: "Yes", description: "Continue" }],
|
||||||
|
custom: true,
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -220,7 +236,7 @@ describe("QuestionTool", () => {
|
|||||||
const fiber = yield* executeTool(registryService, {
|
const fiber = yield* executeTool(registryService, {
|
||||||
sessionID,
|
sessionID,
|
||||||
...toolIdentity,
|
...toolIdentity,
|
||||||
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
|
call: { type: "tool-call", id: "call-question", name: "question", input: questionInput },
|
||||||
}).pipe(Effect.forkScoped)
|
}).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
const exit = yield* Fiber.await(fiber)
|
const exit = yield* Fiber.await(fiber)
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ function ok<T>(data: T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function form(id: string, sessionID: string): FormInfo {
|
function form(id: string, sessionID: string): FormInfo {
|
||||||
return { id, sessionID, title: "Input requested", mode: "form", fields: [] }
|
return {
|
||||||
|
id,
|
||||||
|
sessionID,
|
||||||
|
title: "Input requested",
|
||||||
|
fields: [{ key: "authorization", type: "external", url: "https://example.com/form" }],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formCreated(info: FormInfo): V2Event {
|
function formCreated(info: FormInfo): V2Event {
|
||||||
|
|||||||
@@ -838,13 +838,18 @@ const scenarios: Scenario[] = [
|
|||||||
.at((ctx) => ({
|
.at((ctx) => ({
|
||||||
path: route("/api/session/{sessionID}/form", { sessionID: ctx.state.id }),
|
path: route("/api/session/{sessionID}/form", { sessionID: ctx.state.id }),
|
||||||
headers: ctx.headers(),
|
headers: ctx.headers(),
|
||||||
body: { mode: "url", url: "https://example.com/form" },
|
body: {
|
||||||
|
title: "External form",
|
||||||
|
fields: [{ key: "authorization", type: "external", url: "https://example.com/form" }],
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
.json(200, (body) => {
|
.json(200, (body) => {
|
||||||
object(body)
|
object(body)
|
||||||
object(body.data)
|
object(body.data)
|
||||||
check(typeof body.data.id === "string", "form create should return an ID")
|
check(typeof body.data.id === "string", "form create should return an ID")
|
||||||
check(body.data.mode === "url", "form create should preserve URL mode")
|
array(body.data.fields)
|
||||||
|
object(body.data.fields[0])
|
||||||
|
check(body.data.fields[0].type === "external", "form create should preserve the external field")
|
||||||
}),
|
}),
|
||||||
http.protected
|
http.protected
|
||||||
.get("/api/session/{sessionID}/form/{formID}", "v2.session.form.get")
|
.get("/api/session/{sessionID}/form/{formID}", "v2.session.form.get")
|
||||||
|
|||||||
@@ -14,11 +14,9 @@ import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
|||||||
|
|
||||||
const CreatePayload = Schema.Struct({
|
const CreatePayload = Schema.Struct({
|
||||||
id: Form.ID.pipe(Schema.optional),
|
id: Form.ID.pipe(Schema.optional),
|
||||||
title: Form.FormInfo.fields.title,
|
title: Form.Info.fields.title,
|
||||||
metadata: Form.FormInfo.fields.metadata,
|
metadata: Form.Info.fields.metadata,
|
||||||
mode: Schema.Literals(["form", "url"]),
|
fields: Form.Info.fields.fields,
|
||||||
fields: Form.FormInfo.fields.fields.pipe(Schema.optional),
|
|
||||||
url: Form.UrlInfo.fields.url.pipe(Schema.optional),
|
|
||||||
}).annotate({ identifier: "Form.CreatePayload" })
|
}).annotate({ identifier: "Form.CreatePayload" })
|
||||||
|
|
||||||
export type CreatePayload = typeof CreatePayload.Type
|
export type CreatePayload = typeof CreatePayload.Type
|
||||||
|
|||||||
+25
-19
@@ -94,10 +94,27 @@ export const MultiselectField = Schema.Struct({
|
|||||||
}).annotate({ identifier: "Form.MultiselectField" })
|
}).annotate({ identifier: "Form.MultiselectField" })
|
||||||
export interface MultiselectField extends Schema.Schema.Type<typeof MultiselectField> {}
|
export interface MultiselectField extends Schema.Schema.Type<typeof MultiselectField> {}
|
||||||
|
|
||||||
export const Field = Schema.Union([StringField, NumberField, IntegerField, BooleanField, MultiselectField]).pipe(
|
export const ExternalField = Schema.Struct({
|
||||||
Schema.toTaggedUnion("type"),
|
key: Schema.String,
|
||||||
)
|
type: Schema.Literal("external"),
|
||||||
export type Field = StringField | NumberField | IntegerField | BooleanField | MultiselectField
|
url: Schema.String,
|
||||||
|
title: Schema.String.pipe(optional),
|
||||||
|
description: Schema.String.pipe(optional),
|
||||||
|
}).annotate({ identifier: "Form.ExternalField" })
|
||||||
|
export interface ExternalField extends Schema.Schema.Type<typeof ExternalField> {}
|
||||||
|
|
||||||
|
export const Field = Schema.Union([
|
||||||
|
StringField,
|
||||||
|
NumberField,
|
||||||
|
IntegerField,
|
||||||
|
BooleanField,
|
||||||
|
MultiselectField,
|
||||||
|
ExternalField,
|
||||||
|
]).pipe(Schema.toTaggedUnion("type"), Schema.annotate({ identifier: "Form.Field" }))
|
||||||
|
export type Field = StringField | NumberField | IntegerField | BooleanField | MultiselectField | ExternalField
|
||||||
|
|
||||||
|
export const Fields = Schema.NonEmptyArray(Field).annotate({ identifier: "Form.Fields" })
|
||||||
|
export type Fields = typeof Fields.Type
|
||||||
|
|
||||||
const InfoBase = {
|
const InfoBase = {
|
||||||
id: ID,
|
id: ID,
|
||||||
@@ -110,22 +127,11 @@ const InfoBase = {
|
|||||||
metadata: Metadata.pipe(optional),
|
metadata: Metadata.pipe(optional),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FormInfo = Schema.Struct({
|
export const Info = Schema.Struct({
|
||||||
...InfoBase,
|
...InfoBase,
|
||||||
mode: Schema.Literal("form"),
|
fields: Fields,
|
||||||
fields: Schema.Array(Field),
|
}).annotate({ identifier: "Form.Info" })
|
||||||
}).annotate({ identifier: "Form.FormInfo" })
|
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||||
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
|
|
||||||
|
|
||||||
export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate(
|
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 { DateTime, Schema } from "effect"
|
||||||
import { Agent } from "../src/agent.js"
|
import { Agent } from "../src/agent.js"
|
||||||
import { FileSystem } from "../src/filesystem.js"
|
import { FileSystem } from "../src/filesystem.js"
|
||||||
|
import { Form } from "../src/form.js"
|
||||||
import { Mcp } from "../src/mcp.js"
|
import { Mcp } from "../src/mcp.js"
|
||||||
import { Model } from "../src/model.js"
|
import { Model } from "../src/model.js"
|
||||||
import { Project } from "../src/project.js"
|
import { Project } from "../src/project.js"
|
||||||
@@ -47,6 +48,33 @@ describe("contract hygiene", () => {
|
|||||||
).toEqual({ text: "completed" })
|
).toEqual({ text: "completed" })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("forms require at least one field", () => {
|
||||||
|
expect(() =>
|
||||||
|
Schema.decodeUnknownSync(Form.Info)({
|
||||||
|
id: Form.ID.create(),
|
||||||
|
sessionID: "global",
|
||||||
|
title: "Empty form",
|
||||||
|
fields: [],
|
||||||
|
}),
|
||||||
|
).toThrow()
|
||||||
|
expect(
|
||||||
|
Schema.decodeUnknownSync(Form.Info)({
|
||||||
|
id: Form.ID.create(),
|
||||||
|
sessionID: "global",
|
||||||
|
title: "External form",
|
||||||
|
fields: [{ key: "authorization", type: "external", url: "https://example.com" }],
|
||||||
|
}).fields,
|
||||||
|
).toHaveLength(1)
|
||||||
|
expect(() =>
|
||||||
|
Schema.decodeUnknownSync(Form.Info)({
|
||||||
|
id: Form.ID.create(),
|
||||||
|
sessionID: "global",
|
||||||
|
title: "External form",
|
||||||
|
fields: [{ type: "external", url: "https://example.com" }],
|
||||||
|
}),
|
||||||
|
).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
test("model defaults and provider overlays preserve public invariants", () => {
|
test("model defaults and provider overlays preserve public invariants", () => {
|
||||||
const id = Model.ID.make("model")
|
const id = Model.ID.make("model")
|
||||||
expect(Model.Info.empty(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] })
|
expect(Model.Info.empty(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] })
|
||||||
@@ -69,6 +97,10 @@ describe("contract hygiene", () => {
|
|||||||
const identifiers = [
|
const identifiers = [
|
||||||
Agent.Color,
|
Agent.Color,
|
||||||
FileSystem.Submatch,
|
FileSystem.Submatch,
|
||||||
|
Form.Field,
|
||||||
|
Form.Fields,
|
||||||
|
Form.Info,
|
||||||
|
Form.ExternalField,
|
||||||
Mcp.Resource,
|
Mcp.Resource,
|
||||||
Mcp.ResourceTemplate,
|
Mcp.ResourceTemplate,
|
||||||
Mcp.ResourceCatalog,
|
Mcp.ResourceCatalog,
|
||||||
|
|||||||
@@ -1408,7 +1408,7 @@ export type GlobalEvent = {
|
|||||||
id: string
|
id: string
|
||||||
type: "form.created"
|
type: "form.created"
|
||||||
properties: {
|
properties: {
|
||||||
form: FormFormInfo | FormUrlInfo
|
form: FormInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -3545,22 +3545,30 @@ export type FormMultiselectField = {
|
|||||||
default?: Array<string>
|
default?: Array<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FormFormInfo = {
|
export type FormExternalField = {
|
||||||
id: string
|
key: string
|
||||||
sessionID: string
|
type: "external"
|
||||||
title: string
|
url: string
|
||||||
metadata?: FormMetadata
|
title?: string
|
||||||
mode: "form"
|
description?: string
|
||||||
fields: Array<FormStringField | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectField>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FormUrlInfo = {
|
export type FormField =
|
||||||
|
| FormStringField
|
||||||
|
| FormNumberField
|
||||||
|
| FormIntegerField
|
||||||
|
| FormBooleanField
|
||||||
|
| FormMultiselectField
|
||||||
|
| FormExternalField
|
||||||
|
|
||||||
|
export type FormFields = Array<FormField>
|
||||||
|
|
||||||
|
export type FormInfo = {
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
title: string
|
title: string
|
||||||
metadata?: FormMetadata
|
metadata?: FormMetadata
|
||||||
mode: "url"
|
fields: FormFields
|
||||||
url: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FormValue =
|
export type FormValue =
|
||||||
@@ -5835,9 +5843,7 @@ export type FormCreatePayload = {
|
|||||||
id?: string
|
id?: string
|
||||||
title: string
|
title: string
|
||||||
metadata?: FormMetadata
|
metadata?: FormMetadata
|
||||||
mode: "form" | "url"
|
fields: FormFields
|
||||||
fields?: Array<FormStringField | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectField>
|
|
||||||
url?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FormState =
|
export type FormState =
|
||||||
@@ -6558,7 +6564,7 @@ export type FormCreated = {
|
|||||||
type: "form.created"
|
type: "form.created"
|
||||||
location?: LocationRef
|
location?: LocationRef
|
||||||
data: {
|
data: {
|
||||||
form: FormFormInfo | FormUrlInfo
|
form: FormInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7830,7 +7836,7 @@ export type EventFormCreated = {
|
|||||||
id: string
|
id: string
|
||||||
type: "form.created"
|
type: "form.created"
|
||||||
properties: {
|
properties: {
|
||||||
form: FormFormInfo | FormUrlInfo
|
form: FormInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9888,33 +9894,21 @@ export type FormMultiselectFieldV2 = {
|
|||||||
default?: Array<string>
|
default?: Array<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FormFormInfoV2 = {
|
export type FormFieldsV2 = [FormField, FormField]
|
||||||
id: string
|
|
||||||
sessionID: string
|
|
||||||
title: string
|
|
||||||
metadata?: FormMetadata
|
|
||||||
mode: "form"
|
|
||||||
fields: Array<FormStringFieldV2 | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectFieldV2>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FormUrlInfoV2 = {
|
export type FormInfoV2 = {
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
title: string
|
title: string
|
||||||
metadata?: FormMetadata
|
metadata?: FormMetadata
|
||||||
mode: "url"
|
fields: FormFieldsV2
|
||||||
url: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FormCreatePayloadV2 = {
|
export type FormCreatePayloadV2 = {
|
||||||
id?: string | null
|
id?: string | null
|
||||||
title: string
|
title: string
|
||||||
metadata?: FormMetadata
|
metadata?: FormMetadata
|
||||||
mode: "form" | "url"
|
fields: FormFieldsV2
|
||||||
fields?: Array<
|
|
||||||
FormStringFieldV2 | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectFieldV2
|
|
||||||
> | null
|
|
||||||
url?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FormValueV2 =
|
export type FormValueV2 =
|
||||||
@@ -10623,22 +10617,22 @@ export type FormMultiselectField1 = {
|
|||||||
default?: Array<string>
|
default?: Array<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FormFormInfo1 = {
|
export type FormField1 =
|
||||||
id: string
|
| FormStringField1
|
||||||
sessionID: string
|
| FormNumberField1
|
||||||
title: string
|
| FormIntegerField1
|
||||||
metadata?: FormMetadata1
|
| FormBooleanField1
|
||||||
mode: "form"
|
| FormMultiselectField1
|
||||||
fields: Array<FormStringField1 | FormNumberField1 | FormIntegerField1 | FormBooleanField1 | FormMultiselectField1>
|
| FormExternalField
|
||||||
}
|
|
||||||
|
|
||||||
export type FormUrlInfo1 = {
|
export type FormFields1 = [FormField1, FormField1]
|
||||||
|
|
||||||
|
export type FormInfo1 = {
|
||||||
id: string
|
id: string
|
||||||
sessionID: string
|
sessionID: string
|
||||||
title: string
|
title: string
|
||||||
metadata?: FormMetadata1
|
metadata?: FormMetadata1
|
||||||
mode: "url"
|
fields: FormFields1
|
||||||
url: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FormCreatedV2 = {
|
export type FormCreatedV2 = {
|
||||||
@@ -10650,7 +10644,7 @@ export type FormCreatedV2 = {
|
|||||||
type: "form.created"
|
type: "form.created"
|
||||||
location?: LocationRefV2
|
location?: LocationRefV2
|
||||||
data: {
|
data: {
|
||||||
form: FormFormInfo1 | FormUrlInfo1
|
form: FormInfo1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17240,7 +17234,7 @@ export type V2FormRequestListResponses = {
|
|||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
location: LocationInfoV2
|
location: LocationInfoV2
|
||||||
data: Array<FormFormInfoV2 | FormUrlInfoV2>
|
data: Array<FormInfoV2>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17277,7 +17271,7 @@ export type V2SessionFormListResponses = {
|
|||||||
* Success
|
* Success
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
data: Array<FormFormInfoV2 | FormUrlInfoV2>
|
data: Array<FormInfoV2>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17318,7 +17312,7 @@ export type V2SessionFormCreateResponses = {
|
|||||||
* Success
|
* Success
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
data: FormFormInfoV2 | FormUrlInfoV2
|
data: FormInfoV2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17356,7 +17350,7 @@ export type V2SessionFormGetResponses = {
|
|||||||
* Success
|
* Success
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
data: FormFormInfoV2 | FormUrlInfoV2
|
data: FormInfoV2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,27 +44,19 @@ export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =
|
|||||||
"session.form.create",
|
"session.form.create",
|
||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
const form = yield* Form.Service
|
const form = yield* Form.Service
|
||||||
const common = {
|
const created = yield* form
|
||||||
|
.create({
|
||||||
id: ctx.payload.id,
|
id: ctx.payload.id,
|
||||||
sessionID: ctx.params.sessionID,
|
sessionID: ctx.params.sessionID,
|
||||||
title: ctx.payload.title,
|
title: ctx.payload.title,
|
||||||
metadata: ctx.payload.metadata,
|
metadata: ctx.payload.metadata,
|
||||||
}
|
fields: ctx.payload.fields,
|
||||||
const input = yield* (() => {
|
})
|
||||||
if (ctx.payload.mode === "form") {
|
.pipe(
|
||||||
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({
|
Effect.catchTags({
|
||||||
"Form.AlreadyExistsError": (error) => new ConflictError({ resource: error.id, message: error.message }),
|
"Form.AlreadyExistsError": (error) => new ConflictError({ resource: error.id, message: error.message }),
|
||||||
"Form.InvalidFormError": (error) => new InvalidRequestError({ message: error.message, field: "fields" }),
|
"Form.InvalidFormError": (error) =>
|
||||||
|
new InvalidRequestError({ message: error.message, field: "fields" }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
return { data: created }
|
return { data: created }
|
||||||
|
|||||||
@@ -196,9 +196,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
const api = OpenCode.make(options)
|
const api = OpenCode.make(options)
|
||||||
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
|
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
|
||||||
Effect.map((response) => response.location.directory),
|
Effect.map((response) => response.location.directory),
|
||||||
Effect.catch(() =>
|
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
|
||||||
Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
const reconnectEndpoint = input.server.reconnect
|
const reconnectEndpoint = input.server.reconnect
|
||||||
const reconnect = reconnectEndpoint
|
const reconnect = reconnectEndpoint
|
||||||
@@ -411,11 +409,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function App(props: {
|
function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPluginHost; pair?: DialogPairCredentials }) {
|
||||||
onSnapshot?: () => Promise<string[]>
|
|
||||||
pluginHost: TuiPluginHost
|
|
||||||
pair?: DialogPairCredentials
|
|
||||||
}) {
|
|
||||||
const log = useLog({ component: "app" })
|
const log = useLog({ component: "app" })
|
||||||
const startup = useTuiStartup()
|
const startup = useTuiStartup()
|
||||||
const tuiConfig = useTuiConfig()
|
const tuiConfig = useTuiConfig()
|
||||||
|
|||||||
@@ -6,8 +6,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentInfo,
|
AgentInfo,
|
||||||
CommandInfo,
|
CommandInfo,
|
||||||
FormFormInfo,
|
FormInfo,
|
||||||
FormUrlInfo,
|
|
||||||
IntegrationInfo,
|
IntegrationInfo,
|
||||||
LocationRef,
|
LocationRef,
|
||||||
McpServer,
|
McpServer,
|
||||||
@@ -38,7 +37,7 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
|||||||
// Global MCP elicitations temporarily use "global" instead of a real session ID, so the
|
// 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
|
// server cannot recover their Location when settling them. Preserve the event Location
|
||||||
// until MCP elicitations carry session ownership.
|
// until MCP elicitations carry session ownership.
|
||||||
export type FormInfo = (FormFormInfo | FormUrlInfo) & { readonly location?: LocationRef }
|
export type FormWithLocation = FormInfo & { readonly location?: LocationRef }
|
||||||
|
|
||||||
type LocationData = {
|
type LocationData = {
|
||||||
agent?: AgentInfo[]
|
agent?: AgentInfo[]
|
||||||
@@ -66,7 +65,7 @@ type Data = {
|
|||||||
input: Record<string, string[]>
|
input: Record<string, string[]>
|
||||||
permission: Record<string, PermissionV2Request[]>
|
permission: Record<string, PermissionV2Request[]>
|
||||||
// Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
|
// Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
|
||||||
form: Record<string, FormInfo[]>
|
form: Record<string, FormWithLocation[]>
|
||||||
}
|
}
|
||||||
project: {
|
project: {
|
||||||
permission: Record<string, PermissionSavedInfo[]>
|
permission: Record<string, PermissionSavedInfo[]>
|
||||||
@@ -1033,7 +1032,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
directory: response.location.directory,
|
directory: response.location.directory,
|
||||||
workspaceID: response.location.workspaceID,
|
workspaceID: response.location.workspaceID,
|
||||||
}
|
}
|
||||||
const forms = response.data.reduce<Record<string, FormInfo[]>>(
|
const forms = response.data.reduce<Record<string, FormWithLocation[]>>(
|
||||||
(result, form) => ({
|
(result, form) => ({
|
||||||
...result,
|
...result,
|
||||||
[form.sessionID]: [
|
[form.sessionID]: [
|
||||||
|
|||||||
@@ -4,19 +4,25 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
|||||||
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
|
import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
import { selectedForeground, tint, useTheme } from "../../context/theme"
|
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 type { FormWithLocation } from "../../context/data"
|
||||||
import { useSDK } from "../../context/sdk"
|
import { useSDK } from "../../context/sdk"
|
||||||
|
import { useClipboard } from "../../context/clipboard"
|
||||||
import { SplitBorder } from "../../ui/border"
|
import { SplitBorder } from "../../ui/border"
|
||||||
|
import { useToast } from "../../ui/toast"
|
||||||
import { useTuiConfig } from "../../config"
|
import { useTuiConfig } from "../../config"
|
||||||
import { useBindings, useOpencodeModeStack } from "../../keymap"
|
import { useBindings, useOpencodeModeStack } from "../../keymap"
|
||||||
|
|
||||||
const FORM_MODE = "form"
|
const FORM_MODE = "form"
|
||||||
|
|
||||||
type Field = FormFormInfo["fields"][number]
|
type Field = Exclude<FormField, { type: "external" }>
|
||||||
|
|
||||||
function fieldLabel(field: Field) {
|
function isField(field: FormField): field is Field {
|
||||||
return field.title ?? field.key
|
return field.type !== "external"
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldLabel(field: FormField) {
|
||||||
|
return field.title ?? (field.type === "external" ? field.url : field.key)
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncate(label: string, max: number) {
|
function truncate(label: string, max: number) {
|
||||||
@@ -24,7 +30,7 @@ function truncate(label: string, max: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateText(field: Field, text: string): string | undefined {
|
function validateText(field: Field, text: string): string | undefined {
|
||||||
if (field.type !== "string") return
|
if (field.type !== "string") return undefined
|
||||||
if (field.minLength !== undefined && text.length < field.minLength)
|
if (field.minLength !== undefined && text.length < field.minLength)
|
||||||
return `Must be at least ${field.minLength} characters`
|
return `Must be at least ${field.minLength} characters`
|
||||||
if (field.maxLength !== undefined && text.length > field.maxLength)
|
if (field.maxLength !== undefined && text.length > field.maxLength)
|
||||||
@@ -50,17 +56,19 @@ function validateText(field: Field, text: string): string | undefined {
|
|||||||
return "Expected a date (YYYY-MM-DD)"
|
return "Expected a date (YYYY-MM-DD)"
|
||||||
}
|
}
|
||||||
if (field.format === "date-time" && Number.isNaN(new Date(text).getTime())) return "Expected a date and time"
|
if (field.format === "date-time" && Number.isNaN(new Date(text).getTime())) return "Expected a date and time"
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateSelection(field: Field, value: FormValue | undefined) {
|
function validateSelection(field: Field, value: FormValue | undefined): string | undefined {
|
||||||
if (field.type !== "multiselect" || value === undefined) return
|
if (field.type !== "multiselect" || value === undefined) return undefined
|
||||||
if (!Array.isArray(value)) return "Expected selections"
|
if (!Array.isArray(value)) return "Expected selections"
|
||||||
if (field.required && value.length === 0) return "Select at least one option"
|
if (field.required && value.length === 0) return "Select at least one option"
|
||||||
if (field.minItems !== undefined && value.length < field.minItems) return `Select at least ${field.minItems}`
|
if (field.minItems !== undefined && value.length < field.minItems) return `Select at least ${field.minItems}`
|
||||||
if (field.maxItems !== undefined && value.length > field.maxItems) return `Select at most ${field.maxItems}`
|
if (field.maxItems !== undefined && value.length > field.maxItems) return `Select at most ${field.maxItems}`
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateValue(field: Field, value: FormValue | undefined) {
|
function validateValue(field: Field, value: FormValue | undefined): string | undefined {
|
||||||
if (value === undefined) return field.required ? "Answer required" : undefined
|
if (value === undefined) return field.required ? "Answer required" : undefined
|
||||||
if (field.required && (value === "" || (Array.isArray(value) && value.length === 0))) {
|
if (field.required && (value === "" || (Array.isArray(value) && value.length === 0))) {
|
||||||
return field.type === "multiselect" ? "Select at least one option" : "Answer required"
|
return field.type === "multiselect" ? "Select at least one option" : "Answer required"
|
||||||
@@ -72,14 +80,14 @@ function validateValue(field: Field, value: FormValue | undefined) {
|
|||||||
if (field.options && !field.custom && !field.options.some((option) => option.value === value)) {
|
if (field.options && !field.custom && !field.options.some((option) => option.value === value)) {
|
||||||
return "Select an available option"
|
return "Select an available option"
|
||||||
}
|
}
|
||||||
return
|
return undefined
|
||||||
}
|
}
|
||||||
if (field.type === "number" || field.type === "integer") {
|
if (field.type === "number" || field.type === "integer") {
|
||||||
if (typeof value !== "number" || !Number.isFinite(value)) return "Expected a number"
|
if (typeof value !== "number" || !Number.isFinite(value)) return "Expected a number"
|
||||||
if (field.type === "integer" && !Number.isInteger(value)) return "Expected an integer"
|
if (field.type === "integer" && !Number.isInteger(value)) return "Expected an integer"
|
||||||
if (typeof field.minimum === "number" && value < field.minimum) return `Must be at least ${field.minimum}`
|
if (typeof field.minimum === "number" && value < field.minimum) return `Must be at least ${field.minimum}`
|
||||||
if (typeof field.maximum === "number" && value > field.maximum) return `Must be at most ${field.maximum}`
|
if (typeof field.maximum === "number" && value > field.maximum) return `Must be at most ${field.maximum}`
|
||||||
return
|
return undefined
|
||||||
}
|
}
|
||||||
if (field.type === "boolean") return typeof value === "boolean" ? undefined : "Expected yes or no"
|
if (field.type === "boolean") return typeof value === "boolean" ? undefined : "Expected yes or no"
|
||||||
const invalid = validateSelection(field, value)
|
const invalid = validateSelection(field, value)
|
||||||
@@ -91,6 +99,7 @@ function validateValue(field: Field, value: FormValue | undefined) {
|
|||||||
) {
|
) {
|
||||||
return "Select only available options"
|
return "Select only available options"
|
||||||
}
|
}
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldRows(field: Field): { value: FormValue; label: string; description?: string }[] {
|
function fieldRows(field: Field): { value: FormValue; label: string; description?: string }[] {
|
||||||
@@ -117,11 +126,6 @@ function selectedRow(field: Field | undefined, value: FormValue | undefined) {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function customDefault(field: Field) {
|
|
||||||
if (field.type !== "string" || !field.options || !field.custom || typeof field.default !== "string") return
|
|
||||||
if (!field.options.some((option) => option.value === field.default)) return field.default
|
|
||||||
}
|
|
||||||
|
|
||||||
function display(field: Field, value: FormValue | undefined) {
|
function display(field: Field, value: FormValue | undefined) {
|
||||||
if (value === undefined) return ""
|
if (value === undefined) return ""
|
||||||
const label = (item: string | number | boolean) =>
|
const label = (item: string | number | boolean) =>
|
||||||
@@ -130,7 +134,7 @@ function display(field: Field, value: FormValue | undefined) {
|
|||||||
return label(value)
|
return label(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestOptions(form: FormInfo) {
|
function requestOptions(form: FormWithLocation) {
|
||||||
if (form.sessionID !== "global" || !form.location) return undefined
|
if (form.sessionID !== "global" || !form.location) return undefined
|
||||||
return {
|
return {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -140,107 +144,32 @@ function requestOptions(form: FormInfo) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormPrompt(props: { form: FormInfo }) {
|
export function FormPrompt(props: { form: FormWithLocation }) {
|
||||||
return props.form.mode === "url" ? <UrlPrompt form={props.form} /> : <FieldsPrompt form={props.form} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) {
|
|
||||||
const sdk = useSDK()
|
|
||||||
const { theme } = useTheme()
|
|
||||||
const modeStack = useOpencodeModeStack()
|
|
||||||
const message = createMemo(() => {
|
|
||||||
const value = props.form.metadata?.["message"]
|
|
||||||
return typeof value === "string" ? value : undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
onMount(() => onCleanup(modeStack.push(FORM_MODE)))
|
|
||||||
|
|
||||||
useBindings(() => ({
|
|
||||||
mode: FORM_MODE,
|
|
||||||
enabled: true,
|
|
||||||
commands: [
|
|
||||||
{
|
|
||||||
name: "app.exit",
|
|
||||||
title: "Dismiss form",
|
|
||||||
category: "Form",
|
|
||||||
run() {
|
|
||||||
void sdk.api.form.cancel(
|
|
||||||
{ sessionID: props.form.sessionID, formID: props.form.id },
|
|
||||||
requestOptions(props.form),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
bindings: [
|
|
||||||
{
|
|
||||||
key: "return",
|
|
||||||
desc: "Open link",
|
|
||||||
group: "Form",
|
|
||||||
cmd: () => {
|
|
||||||
void open(props.form.url)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "escape",
|
|
||||||
desc: "Dismiss form",
|
|
||||||
group: "Form",
|
|
||||||
cmd: () => {
|
|
||||||
void sdk.api.form.cancel(
|
|
||||||
{ sessionID: props.form.sessionID, formID: props.form.id },
|
|
||||||
requestOptions(props.form),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
backgroundColor={theme.backgroundPanel}
|
|
||||||
border={["left"]}
|
|
||||||
borderColor={theme.accent}
|
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
|
||||||
>
|
|
||||||
<box gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
|
||||||
<text fg={theme.text}>{props.form.title}</text>
|
|
||||||
<Show when={message()}>
|
|
||||||
<text fg={theme.textMuted}>{message()}</text>
|
|
||||||
</Show>
|
|
||||||
<text fg={theme.secondary}>{props.form.url}</text>
|
|
||||||
</box>
|
|
||||||
<box flexDirection="row" flexShrink={0} gap={2} paddingLeft={2} paddingRight={3} paddingBottom={1}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
enter <span style={{ fg: theme.textMuted }}>open link</span>
|
|
||||||
</text>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const tuiConfig = useTuiConfig()
|
const tuiConfig = useTuiConfig()
|
||||||
const modeStack = useOpencodeModeStack()
|
const modeStack = useOpencodeModeStack()
|
||||||
|
const clipboard = useClipboard()
|
||||||
|
const toast = useToast()
|
||||||
|
const configuredFields = props.form.fields.filter(isField)
|
||||||
|
|
||||||
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
|
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
tab: 0,
|
tab: 0,
|
||||||
answers: Object.fromEntries(
|
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>,
|
) as Record<string, FormValue | undefined>,
|
||||||
custom: Object.fromEntries(
|
custom: Object.fromEntries(
|
||||||
props.form.fields.flatMap((field) => {
|
configuredFields.flatMap((field) => {
|
||||||
const value = customDefault(field)
|
if (field.type !== "string" || !field.options || !field.custom || typeof field.default !== "string") return []
|
||||||
return value === undefined ? [] : [[field.key, value]]
|
if (field.options.some((option) => option.value === field.default)) return []
|
||||||
|
return [[field.key, field.default]]
|
||||||
}),
|
}),
|
||||||
) as Record<string, string>,
|
) as Record<string, string>,
|
||||||
selected: selectedRow(props.form.fields[0], props.form.fields[0]?.default),
|
externalReady: {} as Record<string, boolean>,
|
||||||
|
selected: selectedRow(configuredFields[0], configuredFields[0]?.default),
|
||||||
editing: false,
|
editing: false,
|
||||||
error: "",
|
error: "",
|
||||||
})
|
})
|
||||||
@@ -248,9 +177,14 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
let textarea: TextareaRenderable | undefined
|
let textarea: TextareaRenderable | undefined
|
||||||
let review: ScrollBoxRenderable | undefined
|
let review: ScrollBoxRenderable | undefined
|
||||||
|
|
||||||
|
const message = createMemo(() => {
|
||||||
|
const value = props.form.metadata?.["message"]
|
||||||
|
return typeof value === "string" ? value : undefined
|
||||||
|
})
|
||||||
const fields = createMemo(() => {
|
const fields = createMemo(() => {
|
||||||
const answers: Record<string, FormValue | undefined> = {}
|
const answers: Record<string, FormValue | undefined> = {}
|
||||||
return props.form.fields.filter((field) => {
|
return props.form.fields.filter((field) => {
|
||||||
|
if (field.type === "external") return true
|
||||||
const active = (field.when ?? []).every((when) => {
|
const active = (field.when ?? []).every((when) => {
|
||||||
const value = answers[when.key]
|
const value = answers[when.key]
|
||||||
if (value === undefined) return false
|
if (value === undefined) return false
|
||||||
@@ -263,9 +197,9 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
})
|
})
|
||||||
const single = createMemo(() => {
|
const single = createMemo(() => {
|
||||||
const list = fields()
|
const list = fields()
|
||||||
if (props.form.fields.length !== 1) return false
|
|
||||||
if (list.length !== 1) return false
|
if (list.length !== 1) return false
|
||||||
const field = list[0]!
|
const field = list[0]
|
||||||
|
if (field.type === "external") return false
|
||||||
return field.type === "boolean" || (field.type === "string" && field.options !== undefined)
|
return field.type === "boolean" || (field.type === "string" && field.options !== undefined)
|
||||||
})
|
})
|
||||||
const tabs = createMemo(() => (single() ? 1 : fields().length + 1))
|
const tabs = createMemo(() => (single() ? 1 : fields().length + 1))
|
||||||
@@ -280,10 +214,18 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
return value !== undefined
|
return value !== undefined
|
||||||
}).length,
|
}).length,
|
||||||
)
|
)
|
||||||
const field = createMemo(() => fields()[Math.min(store.tab, fields().length - 1)])
|
const field = createMemo(() => fields()[store.tab])
|
||||||
|
const answerField = createMemo(() => {
|
||||||
|
const current = field()
|
||||||
|
return current && isField(current) ? current : undefined
|
||||||
|
})
|
||||||
|
const externalField = createMemo(() => {
|
||||||
|
const current = field()
|
||||||
|
return current?.type === "external" ? current : undefined
|
||||||
|
})
|
||||||
const confirm = createMemo(() => !single() && store.tab >= fields().length)
|
const confirm = createMemo(() => !single() && store.tab >= fields().length)
|
||||||
const rows = createMemo(() => {
|
const rows = createMemo(() => {
|
||||||
const current = field()
|
const current = answerField()
|
||||||
if (!current) return []
|
if (!current) return []
|
||||||
const configured = fieldRows(current)
|
const configured = fieldRows(current)
|
||||||
const value = store.answers[current.key]
|
const value = store.answers[current.key]
|
||||||
@@ -296,21 +238,32 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
})
|
})
|
||||||
const textual = createMemo(() => {
|
const textual = createMemo(() => {
|
||||||
if (confirm()) return false
|
if (confirm()) return false
|
||||||
const current = field()
|
const current = answerField()
|
||||||
if (!current) return false
|
if (!current) return false
|
||||||
if (current.type === "number" || current.type === "integer") return true
|
if (current.type === "number" || current.type === "integer") return true
|
||||||
return current.type === "string" && current.options === undefined
|
return current.type === "string" && current.options === undefined
|
||||||
})
|
})
|
||||||
const custom = createMemo(() => {
|
const custom = createMemo(() => {
|
||||||
const current = field()
|
const current = answerField()
|
||||||
if (!current) return false
|
if (!current) return false
|
||||||
if (current.type === "string" && current.options !== undefined) return current.custom === true
|
if (current.type === "string" && current.options !== undefined) return current.custom === true
|
||||||
if (current.type === "multiselect") return current.custom === true
|
if (current.type === "multiselect") return current.custom === true
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
const multi = createMemo(() => field()?.type === "multiselect")
|
const multi = createMemo(() => answerField()?.type === "multiselect")
|
||||||
|
const actionLabel = createMemo(() => {
|
||||||
|
if (confirm()) return "submit"
|
||||||
|
const external = externalField()
|
||||||
|
if (external) {
|
||||||
|
if (store.answers[external.key] === true) return "continue"
|
||||||
|
return store.externalReady[external.key] ? "I finished" : "open link"
|
||||||
|
}
|
||||||
|
if (multi()) return "toggle"
|
||||||
|
if (single()) return "submit"
|
||||||
|
return "confirm"
|
||||||
|
})
|
||||||
const placeholder = createMemo(() => {
|
const placeholder = createMemo(() => {
|
||||||
const current = field()
|
const current = answerField()
|
||||||
if (current?.type === "string") {
|
if (current?.type === "string") {
|
||||||
if (current.placeholder) return current.placeholder
|
if (current.placeholder) return current.placeholder
|
||||||
if (current.format === "email") return "name@example.com"
|
if (current.format === "email") return "name@example.com"
|
||||||
@@ -328,11 +281,11 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
return "Type your answer"
|
return "Type your answer"
|
||||||
})
|
})
|
||||||
const other = createMemo(() => custom() && store.selected === rows().length)
|
const other = createMemo(() => custom() && store.selected === rows().length)
|
||||||
const input = createMemo(() => store.custom[field()?.key ?? ""] ?? "")
|
const input = createMemo(() => store.custom[answerField()?.key ?? ""] ?? "")
|
||||||
const customPicked = createMemo(() => {
|
const customPicked = createMemo(() => {
|
||||||
const value = input()
|
const value = input()
|
||||||
if (!value) return false
|
if (!value) return false
|
||||||
const answer = store.answers[field()?.key ?? ""]
|
const answer = store.answers[answerField()?.key ?? ""]
|
||||||
if (Array.isArray(answer)) return answer.includes(value)
|
if (Array.isArray(answer)) return answer.includes(value)
|
||||||
return answer === value
|
return answer === value
|
||||||
})
|
})
|
||||||
@@ -363,7 +316,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pick(value: FormValue, customValue?: string) {
|
function pick(value: FormValue, customValue?: string) {
|
||||||
const current = field()
|
const current = answerField()
|
||||||
if (!current) return
|
if (!current) return
|
||||||
const invalid = validateValue(current, value)
|
const invalid = validateValue(current, value)
|
||||||
if (invalid) {
|
if (invalid) {
|
||||||
@@ -380,7 +333,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggle(value: string) {
|
function toggle(value: string) {
|
||||||
const current = field()
|
const current = answerField()
|
||||||
if (!current) return
|
if (!current) return
|
||||||
const existing = store.answers[current.key]
|
const existing = store.answers[current.key]
|
||||||
const list = Array.isArray(existing) ? [...existing] : []
|
const list = Array.isArray(existing) ? [...existing] : []
|
||||||
@@ -392,7 +345,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
|
|
||||||
function validateCurrent() {
|
function validateCurrent() {
|
||||||
if (confirm()) return true
|
if (confirm()) return true
|
||||||
const current = field()
|
const current = answerField()
|
||||||
if (!current) return true
|
if (!current) return true
|
||||||
const invalid = validateValue(current, store.answers[current.key])
|
const invalid = validateValue(current, store.answers[current.key])
|
||||||
if (!invalid) return true
|
if (!invalid) return true
|
||||||
@@ -404,7 +357,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
if (!confirm() && index > store.tab && !validateCurrent()) return
|
if (!confirm() && index > store.tab && !validateCurrent()) return
|
||||||
const next = fields()[index]
|
const next = fields()[index]
|
||||||
setStore("tab", index)
|
setStore("tab", index)
|
||||||
setStore("selected", selectedRow(next, next ? store.answers[next.key] : undefined))
|
setStore("selected", next && isField(next) ? selectedRow(next, store.answers[next.key]) : 0)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
setStore("error", "")
|
setStore("error", "")
|
||||||
}
|
}
|
||||||
@@ -433,7 +386,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commitInput(text: string) {
|
function commitInput(text: string) {
|
||||||
const current = field()
|
const current = answerField()
|
||||||
if (!current) return false
|
if (!current) return false
|
||||||
const isTextual = textual()
|
const isTextual = textual()
|
||||||
const isMulti = multi()
|
const isMulti = multi()
|
||||||
@@ -507,9 +460,9 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
if (!single()) selectTab((store.tab + direction + tabs()) % tabs())
|
if (!single()) selectTab((store.tab + direction + tabs()) % tabs())
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectTabFromMouse(target?: Field) {
|
function selectTabFromMouse(target?: FormField) {
|
||||||
const targetIndex = () => {
|
const targetIndex = () => {
|
||||||
const index = target ? fields().findIndex((field) => field.key === target.key) : fields().length
|
const index = target ? fields().findIndex((field) => field === target) : fields().length
|
||||||
return index === -1 ? fields().length : index
|
return index === -1 ? fields().length : index
|
||||||
}
|
}
|
||||||
const move = () => selectTab(targetIndex())
|
const move = () => selectTab(targetIndex())
|
||||||
@@ -524,6 +477,83 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
move()
|
move()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
|
||||||
|
}
|
||||||
|
|
||||||
|
function openExternal() {
|
||||||
|
const current = externalField()
|
||||||
|
if (!current) return
|
||||||
|
setStore("error", "")
|
||||||
|
void open(current.url)
|
||||||
|
.then(() => setStore("externalReady", { ...store.externalReady, [current.key]: true }))
|
||||||
|
.catch(() => setStore("error", "Could not open the browser. Copy the URL and continue manually."))
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyExternal() {
|
||||||
|
const current = externalField()
|
||||||
|
if (!current || !clipboard.write) return
|
||||||
|
void clipboard
|
||||||
|
.write(current.url)
|
||||||
|
.then(() => {
|
||||||
|
setStore("externalReady", { ...store.externalReady, [current.key]: true })
|
||||||
|
toast.show({ message: "Copied URL to clipboard", variant: "info" })
|
||||||
|
})
|
||||||
|
.catch(toast.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
function acknowledgeExternal() {
|
||||||
|
const current = externalField()
|
||||||
|
if (!current) return
|
||||||
|
if (store.answers[current.key] === true) {
|
||||||
|
selectTab(store.tab + 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!store.externalReady[current.key]) {
|
||||||
|
openExternal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
answer(current.key, true)
|
||||||
|
selectTab(store.tab + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const unacknowledged = fields().find((field) => field.type === "external" && store.answers[field.key] !== true)
|
||||||
|
if (unacknowledged) {
|
||||||
|
setStore("error", `External action must be acknowledged: ${fieldLabel(unacknowledged)}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const invalid = fields()
|
||||||
|
.filter(isField)
|
||||||
|
.find((field) => validateValue(field, store.answers[field.key]))
|
||||||
|
if (invalid) {
|
||||||
|
setStore("error", validateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sdk.api.form
|
||||||
|
.reply(
|
||||||
|
{
|
||||||
|
sessionID: props.form.sessionID,
|
||||||
|
formID: props.form.id,
|
||||||
|
answer: Object.fromEntries(
|
||||||
|
fields().flatMap((field) => {
|
||||||
|
const value = store.answers[field.key]
|
||||||
|
return value === undefined ? [] : [[field.key, value] as const]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
requestOptions(props.form),
|
||||||
|
)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
setStore(
|
||||||
|
"error",
|
||||||
|
typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
|
||||||
|
? error.message
|
||||||
|
: "Invalid answer",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => onCleanup(modeStack.push(FORM_MODE)))
|
onMount(() => onCleanup(modeStack.push(FORM_MODE)))
|
||||||
|
|
||||||
useBindings(() => ({
|
useBindings(() => ({
|
||||||
@@ -585,7 +615,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
group: "Form",
|
group: "Form",
|
||||||
cmd: () => {
|
cmd: () => {
|
||||||
const text = textarea?.plainText?.trim() ?? ""
|
const text = textarea?.plainText?.trim() ?? ""
|
||||||
const current = field()
|
const current = answerField()
|
||||||
if (!current) return
|
if (!current) return
|
||||||
if (textual()) {
|
if (textual()) {
|
||||||
submitInput(text)
|
submitInput(text)
|
||||||
@@ -606,6 +636,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
useBindings(() => {
|
useBindings(() => {
|
||||||
const total = rows().length + (custom() ? 1 : 0)
|
const total = rows().length + (custom() ? 1 : 0)
|
||||||
const max = Math.min(total, 9)
|
const max = Math.min(total, 9)
|
||||||
|
const external = externalField()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode: FORM_MODE,
|
mode: FORM_MODE,
|
||||||
@@ -615,12 +646,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
name: "app.exit",
|
name: "app.exit",
|
||||||
title: "Dismiss form",
|
title: "Dismiss form",
|
||||||
category: "Form",
|
category: "Form",
|
||||||
run() {
|
run: cancel,
|
||||||
void sdk.api.form.cancel(
|
|
||||||
{ sessionID: props.form.sessionID, formID: props.form.id },
|
|
||||||
requestOptions(props.form),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bindings: [
|
bindings: [
|
||||||
@@ -650,55 +676,36 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
group: "Form",
|
group: "Form",
|
||||||
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
|
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
|
||||||
},
|
},
|
||||||
...(confirm()
|
...(external
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "return",
|
||||||
|
desc:
|
||||||
|
store.answers[external.key] === true
|
||||||
|
? "Continue"
|
||||||
|
: store.externalReady[external.key]
|
||||||
|
? "Confirm completion"
|
||||||
|
: "Open link",
|
||||||
|
group: "Form",
|
||||||
|
cmd: acknowledgeExternal,
|
||||||
|
},
|
||||||
|
{ key: "c", desc: "Copy link", group: "Form", cmd: copyExternal },
|
||||||
|
{ key: "escape", desc: "Dismiss form", group: "Form", cmd: cancel },
|
||||||
|
...tuiConfig.keybinds.get("app.exit"),
|
||||||
|
]
|
||||||
|
: confirm()
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
key: "return",
|
key: "return",
|
||||||
desc: "Submit form",
|
desc: "Submit form",
|
||||||
group: "Form",
|
group: "Form",
|
||||||
cmd: () => {
|
cmd: submit,
|
||||||
const invalid = fields().find((field) => validateValue(field, store.answers[field.key]))
|
|
||||||
if (invalid) {
|
|
||||||
setStore("error", validateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sdk.api.form
|
|
||||||
.reply(
|
|
||||||
{
|
|
||||||
sessionID: props.form.sessionID,
|
|
||||||
formID: props.form.id,
|
|
||||||
answer: Object.fromEntries(
|
|
||||||
fields().flatMap((field) => {
|
|
||||||
const value = store.answers[field.key]
|
|
||||||
return value === undefined ? [] : [[field.key, value] as const]
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
requestOptions(props.form),
|
|
||||||
)
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
setStore(
|
|
||||||
"error",
|
|
||||||
typeof error === "object" &&
|
|
||||||
error !== null &&
|
|
||||||
"message" in error &&
|
|
||||||
typeof error.message === "string"
|
|
||||||
? error.message
|
|
||||||
: "Invalid answer",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "escape",
|
key: "escape",
|
||||||
desc: "Dismiss form",
|
desc: "Dismiss form",
|
||||||
group: "Form",
|
group: "Form",
|
||||||
cmd: () => {
|
cmd: cancel,
|
||||||
void sdk.api.form.cancel(
|
|
||||||
{ sessionID: props.form.sessionID, formID: props.form.id },
|
|
||||||
requestOptions(props.form),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ key: "up", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) },
|
{ key: "up", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) },
|
||||||
{ key: "k", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) },
|
{ key: "k", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) },
|
||||||
@@ -745,12 +752,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
key: "escape",
|
key: "escape",
|
||||||
desc: "Dismiss form",
|
desc: "Dismiss form",
|
||||||
group: "Form",
|
group: "Form",
|
||||||
cmd: () => {
|
cmd: cancel,
|
||||||
void sdk.api.form.cancel(
|
|
||||||
{ sessionID: props.form.sessionID, formID: props.form.id },
|
|
||||||
requestOptions(props.form),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
...tuiConfig.keybinds.get("app.exit"),
|
...tuiConfig.keybinds.get("app.exit"),
|
||||||
]),
|
]),
|
||||||
@@ -769,14 +771,21 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
<text fg={theme.textMuted}>{props.form.title}</text>
|
<text fg={theme.textMuted}>{props.form.title}</text>
|
||||||
</box>
|
</box>
|
||||||
|
<Show when={message()}>
|
||||||
|
<box paddingLeft={1}>
|
||||||
|
<text fg={theme.text}>{message()}</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
<Show when={!single() && !tabbed()}>
|
<Show when={!single() && !tabbed()}>
|
||||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||||
<text fg={theme.textMuted}>
|
<text fg={theme.textMuted}>
|
||||||
{confirm() ? "Review" : `Field ${Math.min(store.tab, fields().length - 1) + 1} of ${fields().length}`}
|
{confirm() ? "Review" : `Field ${Math.min(store.tab, fields().length - 1) + 1} of ${fields().length}`}
|
||||||
</text>
|
</text>
|
||||||
|
<Show when={fields().length > 0}>
|
||||||
<text fg={theme.textMuted}>
|
<text fg={theme.textMuted}>
|
||||||
· {answered()}/{fields().length} answered
|
· {answered()}/{fields().length} completed
|
||||||
</text>
|
</text>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!single() && tabbed()}>
|
<Show when={!single() && tabbed()}>
|
||||||
@@ -828,16 +837,45 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!confirm() && field()}>
|
<Show when={!confirm() && externalField()}>
|
||||||
|
{(external) => (
|
||||||
|
<box paddingLeft={1} gap={1}>
|
||||||
|
<Show when={external().title}>
|
||||||
|
<text fg={theme.text}>{external().title}</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={external().description}>
|
||||||
|
<text fg={theme.textMuted}>{external().description}</text>
|
||||||
|
</Show>
|
||||||
|
<text
|
||||||
|
fg={theme.primary}
|
||||||
|
onMouseUp={() => {
|
||||||
|
if (renderer.getSelection()?.getSelectedText()) return
|
||||||
|
openExternal()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{external().url}
|
||||||
|
</text>
|
||||||
|
<text fg={store.answers[external().key] === true ? theme.success : theme.textMuted}>
|
||||||
|
{store.answers[external().key] === true
|
||||||
|
? "✓ Acknowledged"
|
||||||
|
: store.externalReady[external().key]
|
||||||
|
? "Complete the external action, then press enter to confirm."
|
||||||
|
: "Open or copy the URL, complete the external action, then confirm."}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!confirm() && answerField()}>
|
||||||
<box paddingLeft={1} gap={1}>
|
<box paddingLeft={1} gap={1}>
|
||||||
<box>
|
<box>
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
{field()!.description ?? fieldLabel(field()!)}
|
{answerField()!.description ?? fieldLabel(answerField()!)}
|
||||||
{field()!.required ? " (required)" : ""}
|
{answerField()!.required ? " (required)" : ""}
|
||||||
{multi() ? " (select all that apply)" : ""}
|
{multi() ? " (select all that apply)" : ""}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
<Show when={textual() ? field()!.key : undefined} keyed>
|
<Show when={textual() ? answerField()!.key : undefined} keyed>
|
||||||
<box paddingLeft={1}>
|
<box paddingLeft={1}>
|
||||||
<textarea
|
<textarea
|
||||||
ref={(val: TextareaRenderable) => {
|
ref={(val: TextareaRenderable) => {
|
||||||
@@ -848,7 +886,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
val.gotoLineEnd()
|
val.gotoLineEnd()
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
initialValue={input() || display(field()!, store.answers[field()!.key])}
|
initialValue={input() || display(answerField()!, store.answers[answerField()!.key])}
|
||||||
placeholder={placeholder()}
|
placeholder={placeholder()}
|
||||||
placeholderColor={theme.textMuted}
|
placeholderColor={theme.textMuted}
|
||||||
minHeight={1}
|
minHeight={1}
|
||||||
@@ -865,7 +903,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
{(row, i) => {
|
{(row, i) => {
|
||||||
const active = () => i() === store.selected
|
const active = () => i() === store.selected
|
||||||
const picked = () => {
|
const picked = () => {
|
||||||
const value = store.answers[field()?.key ?? ""]
|
const value = store.answers[answerField()?.key ?? ""]
|
||||||
if (Array.isArray(value)) return value.includes(String(row.value))
|
if (Array.isArray(value)) return value.includes(String(row.value))
|
||||||
return value === row.value
|
return value === row.value
|
||||||
}
|
}
|
||||||
@@ -973,11 +1011,21 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
>
|
>
|
||||||
<For each={fields()}>
|
<For each={fields()}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const value = () => display(item, store.answers[item.key])
|
if (item.type === "external") {
|
||||||
const answered = () => {
|
const acknowledged = () => store.answers[item.key] === true
|
||||||
const value = store.answers[item.key]
|
return (
|
||||||
return value !== undefined
|
<box paddingLeft={1}>
|
||||||
|
<text>
|
||||||
|
<span style={{ fg: theme.textMuted }}>{truncate(fieldLabel(item), 40)}:</span>{" "}
|
||||||
|
<span style={{ fg: acknowledged() ? theme.success : theme.error }}>
|
||||||
|
{acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
const value = () => display(item, store.answers[item.key])
|
||||||
|
const answered = () => store.answers[item.key] !== undefined
|
||||||
const missing = () => !answered() && item.required === true
|
const missing = () => !answered() && item.required === true
|
||||||
const invalid = () => validateValue(item, store.answers[item.key])
|
const invalid = () => validateValue(item, store.answers[item.key])
|
||||||
return (
|
return (
|
||||||
@@ -985,7 +1033,9 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
<text>
|
<text>
|
||||||
<span style={{ fg: theme.textMuted }}>{truncate(fieldLabel(item), 40)}:</span>{" "}
|
<span style={{ fg: theme.textMuted }}>{truncate(fieldLabel(item), 40)}:</span>{" "}
|
||||||
<span
|
<span
|
||||||
style={{ fg: invalid() || missing() ? theme.error : answered() ? theme.text : theme.textMuted }}
|
style={{
|
||||||
|
fg: invalid() || missing() ? theme.error : answered() ? theme.text : theme.textMuted,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
|
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
|
||||||
</span>
|
</span>
|
||||||
@@ -1012,23 +1062,32 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
|
|||||||
{"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
|
{"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!confirm() && !textual()}>
|
<Show when={!confirm() && !textual() && !externalField()}>
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
|
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={confirm()}>
|
<Show when={confirm() && fields().length > 0}>
|
||||||
<text fg={theme.text}>
|
<text fg={theme.text}>
|
||||||
{"↑↓"} <span style={{ fg: theme.textMuted }}>scroll</span>
|
{"↑↓"} <span style={{ fg: theme.textMuted }}>scroll</span>
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<text fg={theme.text}>
|
<text
|
||||||
enter{" "}
|
fg={theme.text}
|
||||||
<span style={{ fg: theme.textMuted }}>
|
onMouseUp={() => {
|
||||||
{confirm() ? "submit" : multi() ? "toggle" : single() ? "submit" : "confirm"}
|
if (renderer.getSelection()?.getSelectedText()) return
|
||||||
</span>
|
if (confirm()) submit()
|
||||||
|
if (externalField()) acknowledgeExternal()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
enter <span style={{ fg: theme.textMuted }}>{actionLabel()}</span>
|
||||||
</text>
|
</text>
|
||||||
<text fg={theme.text}>
|
<Show when={externalField() && clipboard.write}>
|
||||||
|
<text fg={theme.text} onMouseUp={copyExternal}>
|
||||||
|
c <span style={{ fg: theme.textMuted }}>copy</span>
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<text fg={theme.text} onMouseUp={cancel}>
|
||||||
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
|
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -77,16 +77,12 @@ function question(id: string, sessionID = "session"): QuestionRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function form(
|
function form(id: string, sessionID = "session"): Extract<OpenCodeEvent, { type: "form.created" }>["data"]["form"] {
|
||||||
id: string,
|
|
||||||
sessionID = "session",
|
|
||||||
): Extract<OpenCodeEvent, { type: "form.created" }>["data"]["form"] {
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sessionID,
|
sessionID,
|
||||||
title: "Input requested",
|
title: "Input requested",
|
||||||
mode: "form",
|
fields: [{ key: "authorization", type: "external", url: "https://example.com" }],
|
||||||
fields: [],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ import { createSessionRows, type SessionRow } from "../../../src/routes/session/
|
|||||||
import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
|
import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
|
||||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||||
|
|
||||||
|
const formFields = [{ key: "authorization", type: "external", url: "https://example.com" }] satisfies [
|
||||||
|
{
|
||||||
|
key: string
|
||||||
|
type: "external"
|
||||||
|
url: string
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
async function wait(fn: () => boolean, timeout = 2000) {
|
async function wait(fn: () => boolean, timeout = 2000) {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
while (!fn()) {
|
while (!fn()) {
|
||||||
@@ -1510,7 +1518,7 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
|||||||
const calls = createFetch((url) => {
|
const calls = createFetch((url) => {
|
||||||
if (url.pathname !== "/api/session/ses_1/form") return
|
if (url.pathname !== "/api/session/ses_1/form") return
|
||||||
return json({
|
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: formFields }],
|
||||||
})
|
})
|
||||||
}, events)
|
}, events)
|
||||||
let data!: ReturnType<typeof useData>
|
let data!: ReturnType<typeof useData>
|
||||||
@@ -1540,13 +1548,13 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
|||||||
id: "evt_form_created_1",
|
id: "evt_form_created_1",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "form.created",
|
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: formFields } },
|
||||||
})
|
})
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_form_created_duplicate",
|
id: "evt_form_created_duplicate",
|
||||||
created: 1,
|
created: 1,
|
||||||
type: "form.created",
|
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: formFields } },
|
||||||
})
|
})
|
||||||
await wait(() => data.session.form.list("ses_1")?.length === 1)
|
await wait(() => data.session.form.list("ses_1")?.length === 1)
|
||||||
|
|
||||||
@@ -1562,7 +1570,7 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
|||||||
id: "evt_form_created_2",
|
id: "evt_form_created_2",
|
||||||
created: 3,
|
created: 3,
|
||||||
type: "form.created",
|
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: formFields } },
|
||||||
})
|
})
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_form_cancelled_2",
|
id: "evt_form_cancelled_2",
|
||||||
@@ -1612,7 +1620,7 @@ test("tracks global forms by location", async () => {
|
|||||||
location: other,
|
location: other,
|
||||||
type: "form.created",
|
type: "form.created",
|
||||||
data: {
|
data: {
|
||||||
form: { id: "frm_other", sessionID: "global", title: "Input requested", mode: "form", fields: [] },
|
form: { id: "frm_other", sessionID: "global", title: "Input requested", fields: formFields },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1625,7 +1633,7 @@ test("tracks global forms by location", async () => {
|
|||||||
location: { directory },
|
location: { directory },
|
||||||
type: "form.created",
|
type: "form.created",
|
||||||
data: {
|
data: {
|
||||||
form: { id: "frm_default", sessionID: "global", title: "Input requested", mode: "form", fields: [] },
|
form: { id: "frm_default", sessionID: "global", title: "Input requested", fields: formFields },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await wait(() => data.session.form.list("global", { directory })?.length === 1)
|
await wait(() => data.session.form.list("global", { directory })?.length === 1)
|
||||||
@@ -1664,8 +1672,7 @@ test("refreshes global forms for the requested location", async () => {
|
|||||||
id: requestedDirectory === other.directory ? "frm_other" : "frm_default",
|
id: requestedDirectory === other.directory ? "frm_other" : "frm_default",
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
title: "Input requested",
|
title: "Input requested",
|
||||||
mode: "form",
|
fields: formFields,
|
||||||
fields: [],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -1743,8 +1750,7 @@ test("refreshes global forms once per loaded location after reconnect", async ()
|
|||||||
id: `frm_${requestedDirectory === other.directory ? "other" : "default"}_${count}`,
|
id: `frm_${requestedDirectory === other.directory ? "other" : "default"}_${count}`,
|
||||||
sessionID: "global",
|
sessionID: "global",
|
||||||
title: "Input requested",
|
title: "Input requested",
|
||||||
mode: "form",
|
fields: formFields,
|
||||||
fields: [],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -1803,13 +1809,12 @@ test("refreshes global forms once per loaded location after reconnect", async ()
|
|||||||
test("reconciles all pending form requests when the event stream reconnects", async () => {
|
test("reconciles all pending form requests when the event stream reconnects", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
let requests = [
|
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: formFields },
|
||||||
{
|
{
|
||||||
id: "frm_keep",
|
id: "frm_keep",
|
||||||
sessionID: "ses_keep",
|
sessionID: "ses_keep",
|
||||||
title: "Input requested",
|
title: "Input requested",
|
||||||
mode: "url" as const,
|
fields: [{ key: "authorization", type: "external" as const, url: "https://example.com" }],
|
||||||
url: "https://example.com",
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
let calls = 0
|
let calls = 0
|
||||||
@@ -1841,7 +1846,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")
|
await wait(() => data.session.form.list("ses_old")?.[0]?.id === "frm_old")
|
||||||
expect(data.session.form.list("ses_keep")?.[0]?.id).toBe("frm_keep")
|
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: formFields }]
|
||||||
events.disconnect()
|
events.disconnect()
|
||||||
|
|
||||||
await wait(() => calls === 2 && data.session.form.list("ses_new")?.[0]?.id === "frm_new")
|
await wait(() => calls === 2 && data.session.form.list("ses_new")?.[0]?.id === "frm_new")
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
|
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||||
|
import { testRender, useRenderer } from "@opentui/solid"
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { mkdir } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import { onCleanup } from "solid-js"
|
||||||
|
import { ClipboardProvider } from "../../../src/context/clipboard"
|
||||||
|
import type { FormWithLocation } from "../../../src/context/data"
|
||||||
|
import { KVProvider } from "../../../src/context/kv"
|
||||||
|
import { SDKProvider } from "../../../src/context/sdk"
|
||||||
|
import { ThemeProvider } from "../../../src/context/theme"
|
||||||
|
import { TuiConfigProvider } from "../../../src/config"
|
||||||
|
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "../../../src/keymap"
|
||||||
|
import { ToastProvider } from "../../../src/ui/toast"
|
||||||
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
|
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||||
|
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||||
|
import { createApi, createClient, createEventStream, createFetch } from "../../fixture/tui-sdk"
|
||||||
|
|
||||||
|
async function mountForm(root: string, width = 80) {
|
||||||
|
const state = path.join(root, "state")
|
||||||
|
await mkdir(state, { recursive: true })
|
||||||
|
await Bun.write(path.join(state, "kv.json"), "{}")
|
||||||
|
|
||||||
|
const replies: unknown[] = []
|
||||||
|
const copied: string[] = []
|
||||||
|
const events = createEventStream()
|
||||||
|
const transport = createFetch(
|
||||||
|
(url, request) =>
|
||||||
|
url.pathname === "/api/session/ses_test/form/frm_test/reply"
|
||||||
|
? request.json().then((answer) => {
|
||||||
|
replies.push(answer)
|
||||||
|
return new Response(null, { status: 204 })
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
events,
|
||||||
|
)
|
||||||
|
const config = createTuiResolvedConfig()
|
||||||
|
const form = {
|
||||||
|
id: "frm_test",
|
||||||
|
sessionID: "ses_test",
|
||||||
|
title: "Authorization required",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: "authorization",
|
||||||
|
type: "external",
|
||||||
|
url: "https://example.com/authorize",
|
||||||
|
title: "Authorize access",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} satisfies FormWithLocation
|
||||||
|
const { FormPrompt } = await import("../../../src/routes/session/form")
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
const renderer = useRenderer()
|
||||||
|
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||||
|
const off = registerOpencodeKeymap(keymap, renderer, config)
|
||||||
|
onCleanup(off)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TestTuiContexts
|
||||||
|
directory={root}
|
||||||
|
paths={{
|
||||||
|
home: root,
|
||||||
|
state,
|
||||||
|
worktree: root,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClipboardProvider
|
||||||
|
value={{
|
||||||
|
write(text) {
|
||||||
|
copied.push(text)
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<OpencodeKeymapProvider keymap={keymap}>
|
||||||
|
<TuiConfigProvider config={config}>
|
||||||
|
<SDKProvider client={createClient(transport.fetch)} api={createApi(transport.fetch)}>
|
||||||
|
<KVProvider>
|
||||||
|
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||||
|
<ToastProvider>
|
||||||
|
<FormPrompt form={form} />
|
||||||
|
</ToastProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</KVProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</TuiConfigProvider>
|
||||||
|
</OpencodeKeymapProvider>
|
||||||
|
</ClipboardProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => <Harness />, { width, height: 20, kittyKeyboard: true })
|
||||||
|
app.renderer.start()
|
||||||
|
await app.waitForFrame((frame) => frame.includes("Authorization required"))
|
||||||
|
return { app, copied, replies }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("requires explicit acknowledgement before submitting an external field", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const prompt = await mountForm(tmp.path)
|
||||||
|
try {
|
||||||
|
prompt.app.mockInput.pressKey("right")
|
||||||
|
await prompt.app.waitForFrame((frame) => frame.includes("(acknowledgement required)"))
|
||||||
|
prompt.app.mockInput.pressEnter()
|
||||||
|
await prompt.app.waitForFrame((frame) => frame.includes("External action must be acknowledged"))
|
||||||
|
expect(prompt.replies).toEqual([])
|
||||||
|
|
||||||
|
prompt.app.mockInput.pressKey("left")
|
||||||
|
prompt.app.mockInput.pressKey("c")
|
||||||
|
await prompt.app.waitForFrame((frame) => frame.includes("press enter to confirm"))
|
||||||
|
expect(prompt.copied).toEqual(["https://example.com/authorize"])
|
||||||
|
expect(prompt.replies).toEqual([])
|
||||||
|
|
||||||
|
prompt.app.mockInput.pressEnter()
|
||||||
|
await prompt.app.waitForFrame((frame) => frame.includes("Acknowledged"))
|
||||||
|
expect(prompt.replies).toEqual([])
|
||||||
|
|
||||||
|
prompt.app.mockInput.pressEnter()
|
||||||
|
await prompt.app.waitFor(() => prompt.replies.length === 1)
|
||||||
|
expect(prompt.replies).toEqual([{ answer: { authorization: true } }])
|
||||||
|
} finally {
|
||||||
|
prompt.app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("includes external acknowledgements in progress", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const prompt = await mountForm(tmp.path, 32)
|
||||||
|
try {
|
||||||
|
expect(prompt.app.captureCharFrame()).toContain("0/1")
|
||||||
|
expect(prompt.replies).toEqual([])
|
||||||
|
} finally {
|
||||||
|
prompt.app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -5,9 +5,11 @@ export const worktree = "/tmp/opencode"
|
|||||||
export const directory = `${worktree}/packages/tui`
|
export const directory = `${worktree}/packages/tui`
|
||||||
|
|
||||||
export function json(data: unknown, init?: ResponseInit) {
|
export function json(data: unknown, init?: ResponseInit) {
|
||||||
|
const headers = new Headers(init?.headers)
|
||||||
|
if (!headers.has("content-type")) headers.set("content-type", "application/json")
|
||||||
return new Response(JSON.stringify(data), {
|
return new Response(JSON.stringify(data), {
|
||||||
...init,
|
...init,
|
||||||
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
|
headers,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,14 +65,15 @@ export function createEventStream() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FetchHandler = (url: URL) => Response | Promise<Response> | undefined
|
export type FetchHandler = (url: URL, request: Request) => Response | undefined | Promise<Response | undefined>
|
||||||
|
|
||||||
export function createFetch(override?: FetchHandler, events?: ReturnType<typeof createEventStream>) {
|
export function createFetch(override?: FetchHandler, events?: ReturnType<typeof createEventStream>) {
|
||||||
const session = [] as URL[]
|
const session = [] as URL[]
|
||||||
const fetch = (async (input: RequestInfo | URL) => {
|
async function fetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||||
const url = new URL(input instanceof Request ? input.url : String(input))
|
const request = input instanceof Request ? input : new Request(input, init)
|
||||||
|
const url = new URL(request.url)
|
||||||
if (url.pathname === "/session") session.push(url)
|
if (url.pathname === "/session") session.push(url)
|
||||||
const overridden = await override?.(url)
|
const overridden = await override?.(url, request)
|
||||||
if (overridden) return overridden
|
if (overridden) return overridden
|
||||||
if (url.pathname === "/api/event" && events) return events.v2()
|
if (url.pathname === "/api/event" && events) return events.v2()
|
||||||
|
|
||||||
@@ -122,7 +125,8 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
|||||||
if (url.pathname === "/session") return json([])
|
if (url.pathname === "/session") return json([])
|
||||||
if (url.pathname === "/vcs") return json({ branch: "main" })
|
if (url.pathname === "/vcs") return json({ branch: "main" })
|
||||||
throw new Error(`unexpected request: ${url.pathname}`)
|
throw new Error(`unexpected request: ${url.pathname}`)
|
||||||
}) as typeof globalThis.fetch
|
}
|
||||||
|
fetch.preconnect = () => {}
|
||||||
return { fetch, session }
|
return { fetch, session }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user