Răsfoiți Sursa

refactor(form): model links as fields (#36129)

Aiden Cline 1 lună în urmă
părinte
comite
a6449cb45c

+ 1 - 3
packages/client/src/effect/api/api.ts

@@ -550,9 +550,7 @@ export type Endpoint14_2Input = {
   readonly id?: Endpoint14_2Request["payload"]["id"]
   readonly title: Endpoint14_2Request["payload"]["title"]
   readonly metadata?: Endpoint14_2Request["payload"]["metadata"]
-  readonly mode: Endpoint14_2Request["payload"]["mode"]
-  readonly fields?: Endpoint14_2Request["payload"]["fields"]
-  readonly url?: Endpoint14_2Request["payload"]["url"]
+  readonly fields: Endpoint14_2Request["payload"]["fields"]
 }
 export type Endpoint14_2Output = EffectValue<ReturnType<RawClient["server.form"]["session.form.create"]>>["data"]
 export type FormCreateOperation<E = never> = (input: Endpoint14_2Input) => Effect.Effect<Endpoint14_2Output, E>

+ 2 - 11
packages/client/src/effect/generated/client.ts

@@ -655,21 +655,12 @@ type Endpoint14_2Input = {
   readonly id?: Endpoint14_2Request["payload"]["id"]
   readonly title: Endpoint14_2Request["payload"]["title"]
   readonly metadata?: Endpoint14_2Request["payload"]["metadata"]
-  readonly mode: Endpoint14_2Request["payload"]["mode"]
-  readonly fields?: Endpoint14_2Request["payload"]["fields"]
-  readonly url?: Endpoint14_2Request["payload"]["url"]
+  readonly fields: Endpoint14_2Request["payload"]["fields"]
 }
 const Endpoint14_2 = (raw: RawClient["server.form"]) => (input: Endpoint14_2Input) =>
   raw["session.form.create"]({
     params: { sessionID: input["sessionID"] },
-    payload: {
-      id: input["id"],
-      title: input["title"],
-      metadata: input["metadata"],
-      mode: input["mode"],
-      fields: input["fields"],
-      url: input["url"],
-    },
+    payload: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
   }).pipe(
     Effect.mapError(mapClientError),
     Effect.map((value) => value.data),

+ 1 - 8
packages/client/src/promise/generated/client.ts

@@ -1056,14 +1056,7 @@ export function make(options: ClientOptions) {
           {
             method: "POST",
             path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
-            body: {
-              id: input["id"],
-              title: input["title"],
-              metadata: input["metadata"],
-              mode: input["mode"],
-              fields: input["fields"],
-              url: input["url"],
-            },
+            body: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
             successStatus: 200,
             declaredStatuses: [404, 409, 400, 401],
             empty: false,

Fișier diff suprimat deoarece este prea mare
+ 1252 - 783
packages/client/src/promise/generated/types.ts


+ 25 - 23
packages/core/src/form.ts

@@ -16,6 +16,9 @@ export type Info = typeof Info.Type
 export const Field = Form.Field
 export type Field = Form.Field
 
+export const Fields = Form.Fields
+export type Fields = Form.Fields
+
 export const When = Form.When
 export type When = Form.When
 
@@ -64,9 +67,7 @@ export class InvalidFormError extends Schema.TaggedErrorClass<InvalidFormError>(
   message: Schema.String,
 }) {}
 
-export type CreateInput =
-  | (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
-  | (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
+export type CreateInput = Omit<Form.Info, "id"> & { readonly id?: ID }
 
 export interface ReplyInput {
   readonly id: ID
@@ -74,7 +75,7 @@ export interface ReplyInput {
 }
 
 export interface ListInput {
-  readonly sessionID?: Form.FormInfo["sessionID"]
+  readonly sessionID?: Form.Info["sessionID"]
 }
 
 export interface Interface {
@@ -125,20 +126,15 @@ export const layer = Layer.effect(
           const id = input.id ?? ID.create()
           const existing = yield* Cache.getSuccess(forms, id)
           if (Option.isSome(existing)) return yield* new AlreadyExistsError({ id })
-          if (input.mode === "form") {
-            const invalid = validateFields(input.fields)
-            if (invalid) return yield* new InvalidFormError({ message: invalid })
-          }
-          const base = {
+          const invalid = validateFields(input.fields)
+          if (invalid) return yield* new InvalidFormError({ message: invalid })
+          const form: Info = {
             id,
             sessionID: input.sessionID,
             title: input.title,
             ...(input.metadata === undefined ? {} : { metadata: input.metadata }),
+            fields: input.fields,
           }
-          const form: Info =
-            input.mode === "form"
-              ? { ...base, mode: "form", fields: input.fields }
-              : { ...base, mode: "url", url: input.url }
           const entry: Entry = {
             form,
             state: { status: "pending" },
@@ -228,16 +224,16 @@ export const locationLayer = layer
 export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
 
 function validateAnswer(form: Info, answer: Answer) {
-  if (form.mode === "url") {
-    if (Object.keys(answer).length === 0) return
-    return "URL forms must be answered with an empty answer"
-  }
-  const fields = new Map(form.fields.map((field) => [field.key, field]))
+  const fields = new Map(form.fields.map((field) => [field.key, field] as const))
   for (const key of Object.keys(answer)) {
     if (!fields.has(key)) return `Unknown form field: ${key}`
   }
   for (const field of form.fields) {
     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)
     if (value === undefined) {
       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
   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
 // silently never matching.
 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) {
-    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 ?? []) {
       const target = earlier.get(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 (typeof when.value !== "boolean") return "Form field condition value must be a boolean"
     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 (typeof value !== "string") return `Expected string for form field: ${field.key}`
     if (field.required && value.length === 0) return `Missing required form field: ${field.key}`

+ 8 - 7
packages/core/src/mcp/index.ts

@@ -123,6 +123,7 @@ type ServerEntry = {
 // 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.
 const GLOBAL_ELICITATION_SESSION_ID = "global"
+const URL_ELICITATION_FIELD_KEY = "elicitation"
 
 export interface Interface {
   readonly servers: () => Effect.Effect<ServerInfo[]>
@@ -311,8 +312,7 @@ export const layer = Layer.effect(
                   elicitationID: input.params.elicitationId,
                   message: input.params.message,
                 },
-                mode: "url",
-                url: input.params.url,
+                fields: [{ key: URL_ELICITATION_FIELD_KEY, type: "external", url: input.params.url }],
               })
               .pipe(
                 Effect.raceFirst(waitForAbort(input.signal)),
@@ -325,15 +325,16 @@ export const layer = Layer.effect(
               )
           }
           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
             .ask({
               sessionID: GLOBAL_ELICITATION_SESSION_ID,
               title: `${input.server} is requesting input`,
               metadata: { kind: "mcp-elicitation", server: input.server, message: params.message },
-              mode: "form",
-              fields: Object.entries(params.requestedSchema.properties).map(([key, property]) =>
-                toElicitationField(key, property, params.requestedSchema.required?.includes(key) === true),
-              ),
+              fields: [field, ...fields],
             })
             .pipe(
               Effect.raceFirst(waitForAbort(input.signal)),
@@ -355,7 +356,7 @@ export const layer = Layer.effect(
         Effect.gen(function* () {
           const formID = urlElicitations.get(input.server + "\u0000" + input.elicitationID)
           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
 

+ 20 - 16
packages/core/src/tool/question.ts

@@ -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`
 
 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({
@@ -86,21 +86,10 @@ export const Plugin = {
                           kind: "question",
                           tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
                         },
-                        mode: "form",
-                        fields: input.questions.map(
-                          (question, index): Form.Field => ({
-                            key: `q${index}`,
-                            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,
-                          }),
-                        ),
+                        fields: [
+                          toField(input.questions[0], 0),
+                          ...input.questions.slice(1).map((question, index) => toField(question, index + 1)),
+                        ],
                       })
                       .pipe(Effect.orDie),
                   ),
@@ -122,3 +111,18 @@ export const Plugin = {
       .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,
+  }
+}

+ 70 - 20
packages/core/test/form.test.ts

@@ -15,7 +15,6 @@ const input = {
   id: formID,
   sessionID: SessionSchema.ID.make("ses_test"),
   title: "Test form",
-  mode: "form",
   fields: [{ key: "name", type: "string", required: true }],
 } satisfies Form.CreateInput
 
@@ -47,7 +46,6 @@ describe("Form", () => {
       const created = yield* service.create({
         sessionID: "global",
         title: "MCP input",
-        mode: "form",
         fields: [{ key: "name", type: "string", required: true }],
       })
       expect(created.sessionID).toBe("global")
@@ -59,6 +57,14 @@ describe("Form", () => {
 
       yield* service.reply({ id: created.id, answer: { name: "Ava" } })
       expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { name: "Ava" } })
+
+      const 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({
         sessionID: "global",
         title: "Conditional form",
-        mode: "form",
         fields: [
           { key: "confirm", type: "boolean", required: true },
           { key: "reason", type: "string", required: true, when: [{ key: "confirm", op: "eq", value: false }] },
         ],
       })
 
-      const inactive = yield* service.reply({ id: created.id, answer: { confirm: true, reason: "x" } }).pipe(Effect.flip)
-      expect(inactive).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: reason" }))
+      const inactive = yield* service
+        .reply({ id: created.id, answer: { confirm: true, reason: "x" } })
+        .pipe(Effect.flip)
+      expect(inactive).toEqual(
+        new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: reason" }),
+      )
 
       const missing = yield* service.reply({ id: created.id, answer: { confirm: false } }).pipe(Effect.flip)
       expect(missing).toEqual(
@@ -101,7 +110,6 @@ describe("Form", () => {
       const created = yield* service.create({
         sessionID: "global",
         title: "Multiselect form",
-        mode: "form",
         fields: [
           { key: "langs", type: "multiselect", options },
           { key: "goVersion", type: "string", required: true, when: [{ key: "langs", op: "eq", value: "go" }] },
@@ -124,7 +132,6 @@ describe("Form", () => {
       const created = yield* service.create({
         sessionID: "global",
         title: "Dependent form",
-        mode: "form",
         fields: [
           { key: "a", type: "boolean" },
           { key: "b", type: "boolean" },
@@ -142,7 +149,9 @@ describe("Form", () => {
       })
 
       const missingX = yield* service.reply({ id: created.id, answer: { a: true, b: true, z: "ok" } }).pipe(Effect.flip)
-      expect(missingX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: x" }))
+      expect(missingX).toEqual(
+        new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: x" }),
+      )
 
       const inactiveX = yield* service
         .reply({ id: created.id, answer: { a: true, b: false, x: "nope", z: "ok" } })
@@ -150,7 +159,9 @@ describe("Form", () => {
       expect(inactiveX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: x" }))
 
       const missingZ = yield* service.reply({ id: created.id, answer: { a: true, b: false } }).pipe(Effect.flip)
-      expect(missingZ).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: z" }))
+      expect(missingZ).toEqual(
+        new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: z" }),
+      )
 
       yield* service.reply({ id: created.id, answer: { a: true, b: false, z: "ok" } })
       expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { a: true, b: false, z: "ok" } })
@@ -167,7 +178,6 @@ describe("Form", () => {
       const created = yield* service.create({
         sessionID: "global",
         title: "Selection form",
-        mode: "form",
         fields: [
           { key: "langs", type: "multiselect", options },
           { key: "note", type: "string", required: true, when: [{ key: "langs", op: "neq", value: "go" }] },
@@ -186,7 +196,9 @@ describe("Form", () => {
       )
 
       const inactive = yield* service.reply({ id: created.id, answer: { langs: ["go"], note: "x" } }).pipe(Effect.flip)
-      expect(inactive).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: note" }))
+      expect(inactive).toEqual(
+        new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: note" }),
+      )
 
       yield* service.reply({ id: created.id, answer: { langs: ["go"] } })
       expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { langs: ["go"] } })
@@ -199,7 +211,6 @@ describe("Form", () => {
       const created = yield* service.create({
         sessionID: "global",
         title: "Cascading form",
-        mode: "form",
         fields: [
           { key: "a", type: "boolean" },
           { key: "b", type: "string", when: [{ key: "a", op: "eq", value: true }] },
@@ -220,18 +231,25 @@ describe("Form", () => {
   it.effect("rejects invalid when definitions at creation", () =>
     Effect.gen(function* () {
       const service = yield* Form.Service
-      const flipCreate = (fields: ReadonlyArray<Form.Field>) =>
-        service.create({ sessionID: "global", title: "Invalid form", mode: "form", fields }).pipe(Effect.flip)
+      const flipCreate = (fields: Form.CreateInput["fields"]) =>
+        service.create({ sessionID: "global", title: "Invalid form", fields }).pipe(Effect.flip)
+
+      expect(
+        yield* flipCreate([{ key: "b", type: "string", when: [{ key: "missing", op: "eq", value: "x" }] }]),
+      ).toEqual(
+        new Form.InvalidFormError({ message: "Form field condition must reference an earlier field: b -> missing" }),
+      )
 
       expect(
         yield* flipCreate([
-          { key: "b", type: "string", when: [{ key: "missing", op: "eq", value: "x" }] },
+          { key: "a", type: "string" },
+          { key: "a", type: "string" },
         ]),
-      ).toEqual(new Form.InvalidFormError({ message: "Form field condition must reference an earlier field: b -> missing" }))
+      ).toEqual(new Form.InvalidFormError({ message: "Duplicate form field key: a" }))
 
       expect(
         yield* flipCreate([
-          { key: "a", type: "string" },
+          { key: "a", type: "external", url: "https://example.com" },
           { key: "a", type: "string" },
         ]),
       ).toEqual(new Form.InvalidFormError({ message: "Duplicate form field key: a" }))
@@ -241,9 +259,7 @@ describe("Form", () => {
           { key: "a", type: "boolean" },
           { key: "b", type: "string", when: [{ key: "a", op: "eq", value: "yes" }] },
         ]),
-      ).toEqual(
-        new Form.InvalidFormError({ message: "Form field condition value must be a boolean: b -> a" }),
-      )
+      ).toEqual(new Form.InvalidFormError({ message: "Form field condition value must be a boolean: b -> a" }))
 
       expect(
         yield* flipCreate([
@@ -258,6 +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", () =>
     Effect.gen(function* () {
       const service = yield* Form.Service

+ 96 - 8
packages/core/test/mcp.test.ts

@@ -28,7 +28,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
 import { McpTool } from "@opencode-ai/core/tool/mcp"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 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 { location } from "./fixture/location"
 import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
@@ -47,7 +47,9 @@ type ResourceTemplatePage = {
   nextCursor?: string
 }
 
-function resourceServer(input: { resources?: boolean; listChanged?: boolean } = {}) {
+function resourceServer(
+  input: { resources?: boolean; listChanged?: boolean; emptyElicitation?: boolean; urlElicitation?: boolean } = {},
+) {
   return Effect.acquireRelease(
     Effect.promise(async () => {
       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) {
         protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
           state.resourceLists += 1
@@ -98,6 +135,7 @@ function resourceServer(input: { resources?: boolean; listChanged?: boolean } =
         state,
         url: http.url.toString(),
         sendResourceListChanged: () => protocol.sendResourceListChanged(),
+        completeElicitation: () => protocol.createElicitationCompletionNotifier("elicitation-test")(),
         close: async () => {
           await protocol.close().catch(() => {})
           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 unusedIntegration = () => Effect.die("unused integration service")
   return MCP.layer.pipe(
+    Layer.provideMerge(Form.layer),
     Layer.provide(
       Layer.mergeAll(
         Layer.succeed(
@@ -133,14 +172,16 @@ function resourceMcpLayer(url: string) {
         Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
         Layer.mock(EventV2.Service, {
           subscribe: () => Stream.never,
-          publish: (definition, data) =>
-            Effect.succeed({
+          publish: (definition, data) => {
+            const event = {
               id: EventV2.ID.create(),
               type: definition.type,
               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, {
           connection: {
             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 () => {
   await Effect.runPromise(
     Effect.scoped(

+ 22 - 6
packages/core/test/tool-question.test.ts

@@ -18,6 +18,15 @@ let captured: Form.CreateInput | undefined
 let reject = false
 let deny = false
 const capturedInput = () => captured
+const questionInput = {
+  questions: [
+    {
+      question: "Continue?",
+      header: "Continue",
+      options: [{ label: "Yes", description: "Continue" }],
+    },
+  ],
+}
 const permission = Layer.succeed(
   PermissionV2.Service,
   PermissionV2.Service.of({
@@ -90,7 +99,7 @@ describe("QuestionTool", () => {
         yield* settleTool(registry, {
           sessionID,
           ...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({
         result: { type: "error", value: "Permission denied: question" },
@@ -158,7 +167,6 @@ describe("QuestionTool", () => {
         sessionID,
         title: "Questions",
         metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
-        mode: "form",
         fields: [
           {
             key: "q0",
@@ -199,14 +207,22 @@ describe("QuestionTool", () => {
       yield* executeTool(registryService, {
         sessionID,
         ...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({
         sessionID,
         title: "Questions",
         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, {
         sessionID,
         ...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)
 
       const exit = yield* Fiber.await(fiber)

+ 6 - 1
packages/opencode/test/cli/run/noninteractive.test.ts

@@ -10,7 +10,12 @@ function ok<T>(data: T) {
 }
 
 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 {

+ 7 - 2
packages/opencode/test/server/httpapi-exercise/index.ts

@@ -838,13 +838,18 @@ const scenarios: Scenario[] = [
     .at((ctx) => ({
       path: route("/api/session/{sessionID}/form", { sessionID: ctx.state.id }),
       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) => {
       object(body)
       object(body.data)
       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
     .get("/api/session/{sessionID}/form/{formID}", "v2.session.form.get")

+ 3 - 5
packages/protocol/src/groups/form.ts

@@ -14,11 +14,9 @@ import { LocationQuery, locationQueryOpenApi } from "./location.js"
 
 const CreatePayload = Schema.Struct({
   id: Form.ID.pipe(Schema.optional),
-  title: Form.FormInfo.fields.title,
-  metadata: Form.FormInfo.fields.metadata,
-  mode: Schema.Literals(["form", "url"]),
-  fields: Form.FormInfo.fields.fields.pipe(Schema.optional),
-  url: Form.UrlInfo.fields.url.pipe(Schema.optional),
+  title: Form.Info.fields.title,
+  metadata: Form.Info.fields.metadata,
+  fields: Form.Info.fields.fields,
 }).annotate({ identifier: "Form.CreatePayload" })
 
 export type CreatePayload = typeof CreatePayload.Type

+ 25 - 19
packages/schema/src/form.ts

@@ -94,10 +94,27 @@ export const MultiselectField = Schema.Struct({
 }).annotate({ identifier: "Form.MultiselectField" })
 export interface MultiselectField extends Schema.Schema.Type<typeof MultiselectField> {}
 
-export const Field = Schema.Union([StringField, NumberField, IntegerField, BooleanField, MultiselectField]).pipe(
-  Schema.toTaggedUnion("type"),
-)
-export type Field = StringField | NumberField | IntegerField | BooleanField | MultiselectField
+export const ExternalField = Schema.Struct({
+  key: Schema.String,
+  type: Schema.Literal("external"),
+  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 = {
   id: ID,
@@ -110,22 +127,11 @@ const InfoBase = {
   metadata: Metadata.pipe(optional),
 }
 
-export const FormInfo = Schema.Struct({
+export const Info = Schema.Struct({
   ...InfoBase,
-  mode: Schema.Literal("form"),
-  fields: Schema.Array(Field),
-}).annotate({ identifier: "Form.FormInfo" })
-export interface FormInfo extends Schema.Schema.Type<typeof FormInfo> {}
-
-export const UrlInfo = Schema.Struct({
-  ...InfoBase,
-  mode: Schema.Literal("url"),
-  url: Schema.String,
-}).annotate({ identifier: "Form.UrlInfo" })
-export interface UrlInfo extends Schema.Schema.Type<typeof UrlInfo> {}
-
-export const Info = Schema.Union([FormInfo, UrlInfo]).pipe(Schema.toTaggedUnion("mode"))
-export type Info = FormInfo | UrlInfo
+  fields: Fields,
+}).annotate({ identifier: "Form.Info" })
+export interface Info extends Schema.Schema.Type<typeof Info> {}
 
 export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate(
   {

+ 32 - 0
packages/schema/test/contract-hygiene.test.ts

@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
 import { DateTime, Schema } from "effect"
 import { Agent } from "../src/agent.js"
 import { FileSystem } from "../src/filesystem.js"
+import { Form } from "../src/form.js"
 import { Mcp } from "../src/mcp.js"
 import { Model } from "../src/model.js"
 import { Project } from "../src/project.js"
@@ -47,6 +48,33 @@ describe("contract hygiene", () => {
     ).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", () => {
     const id = Model.ID.make("model")
     expect(Model.Info.empty(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] })
@@ -69,6 +97,10 @@ describe("contract hygiene", () => {
     const identifiers = [
       Agent.Color,
       FileSystem.Submatch,
+      Form.Field,
+      Form.Fields,
+      Form.Info,
+      Form.ExternalField,
       Mcp.Resource,
       Mcp.ResourceTemplate,
       Mcp.ResourceCatalog,

+ 42 - 48
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -1408,7 +1408,7 @@ export type GlobalEvent = {
         id: string
         type: "form.created"
         properties: {
-          form: FormFormInfo | FormUrlInfo
+          form: FormInfo
         }
       }
     | {
@@ -3545,22 +3545,30 @@ export type FormMultiselectField = {
   default?: Array<string>
 }
 
-export type FormFormInfo = {
-  id: string
-  sessionID: string
-  title: string
-  metadata?: FormMetadata
-  mode: "form"
-  fields: Array<FormStringField | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectField>
+export type FormExternalField = {
+  key: string
+  type: "external"
+  url: string
+  title?: string
+  description?: string
 }
 
-export type FormUrlInfo = {
+export type FormField =
+  | FormStringField
+  | FormNumberField
+  | FormIntegerField
+  | FormBooleanField
+  | FormMultiselectField
+  | FormExternalField
+
+export type FormFields = Array<FormField>
+
+export type FormInfo = {
   id: string
   sessionID: string
   title: string
   metadata?: FormMetadata
-  mode: "url"
-  url: string
+  fields: FormFields
 }
 
 export type FormValue =
@@ -5835,9 +5843,7 @@ export type FormCreatePayload = {
   id?: string
   title: string
   metadata?: FormMetadata
-  mode: "form" | "url"
-  fields?: Array<FormStringField | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectField>
-  url?: string
+  fields: FormFields
 }
 
 export type FormState =
@@ -6558,7 +6564,7 @@ export type FormCreated = {
   type: "form.created"
   location?: LocationRef
   data: {
-    form: FormFormInfo | FormUrlInfo
+    form: FormInfo
   }
 }
 
@@ -7830,7 +7836,7 @@ export type EventFormCreated = {
   id: string
   type: "form.created"
   properties: {
-    form: FormFormInfo | FormUrlInfo
+    form: FormInfo
   }
 }
 
@@ -9888,33 +9894,21 @@ export type FormMultiselectFieldV2 = {
   default?: Array<string>
 }
 
-export type FormFormInfoV2 = {
-  id: string
-  sessionID: string
-  title: string
-  metadata?: FormMetadata
-  mode: "form"
-  fields: Array<FormStringFieldV2 | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectFieldV2>
-}
+export type FormFieldsV2 = [FormField, FormField]
 
-export type FormUrlInfoV2 = {
+export type FormInfoV2 = {
   id: string
   sessionID: string
   title: string
   metadata?: FormMetadata
-  mode: "url"
-  url: string
+  fields: FormFieldsV2
 }
 
 export type FormCreatePayloadV2 = {
   id?: string | null
   title: string
   metadata?: FormMetadata
-  mode: "form" | "url"
-  fields?: Array<
-    FormStringFieldV2 | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectFieldV2
-  > | null
-  url?: string | null
+  fields: FormFieldsV2
 }
 
 export type FormValueV2 =
@@ -10623,22 +10617,22 @@ export type FormMultiselectField1 = {
   default?: Array<string>
 }
 
-export type FormFormInfo1 = {
-  id: string
-  sessionID: string
-  title: string
-  metadata?: FormMetadata1
-  mode: "form"
-  fields: Array<FormStringField1 | FormNumberField1 | FormIntegerField1 | FormBooleanField1 | FormMultiselectField1>
-}
+export type FormField1 =
+  | FormStringField1
+  | FormNumberField1
+  | FormIntegerField1
+  | FormBooleanField1
+  | FormMultiselectField1
+  | FormExternalField
+
+export type FormFields1 = [FormField1, FormField1]
 
-export type FormUrlInfo1 = {
+export type FormInfo1 = {
   id: string
   sessionID: string
   title: string
   metadata?: FormMetadata1
-  mode: "url"
-  url: string
+  fields: FormFields1
 }
 
 export type FormCreatedV2 = {
@@ -10650,7 +10644,7 @@ export type FormCreatedV2 = {
   type: "form.created"
   location?: LocationRefV2
   data: {
-    form: FormFormInfo1 | FormUrlInfo1
+    form: FormInfo1
   }
 }
 
@@ -17240,7 +17234,7 @@ export type V2FormRequestListResponses = {
    */
   200: {
     location: LocationInfoV2
-    data: Array<FormFormInfoV2 | FormUrlInfoV2>
+    data: Array<FormInfoV2>
   }
 }
 
@@ -17277,7 +17271,7 @@ export type V2SessionFormListResponses = {
    * Success
    */
   200: {
-    data: Array<FormFormInfoV2 | FormUrlInfoV2>
+    data: Array<FormInfoV2>
   }
 }
 
@@ -17318,7 +17312,7 @@ export type V2SessionFormCreateResponses = {
    * Success
    */
   200: {
-    data: FormFormInfoV2 | FormUrlInfoV2
+    data: FormInfoV2
   }
 }
 
@@ -17356,7 +17350,7 @@ export type V2SessionFormGetResponses = {
    * Success
    */
   200: {
-    data: FormFormInfoV2 | FormUrlInfoV2
+    data: FormInfoV2
   }
 }
 

+ 15 - 23
packages/server/src/handlers/form.ts

@@ -44,29 +44,21 @@ export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =
         "session.form.create",
         Effect.fn(function* (ctx) {
           const form = yield* Form.Service
-          const common = {
-            id: ctx.payload.id,
-            sessionID: ctx.params.sessionID,
-            title: ctx.payload.title,
-            metadata: ctx.payload.metadata,
-          }
-          const input = yield* (() => {
-            if (ctx.payload.mode === "form") {
-              if (!ctx.payload.fields) {
-                return new InvalidRequestError({ message: "Form fields are required", field: "fields" })
-              }
-              return Effect.succeed({ ...common, mode: "form" as const, fields: ctx.payload.fields })
-            }
-            if (!ctx.payload.url) return new InvalidRequestError({ message: "Form URL is required", field: "url" })
-            return Effect.succeed({ ...common, mode: "url" as const, url: ctx.payload.url })
-          })()
-
-          const created = yield* form.create(input).pipe(
-            Effect.catchTags({
-              "Form.AlreadyExistsError": (error) => new ConflictError({ resource: error.id, message: error.message }),
-              "Form.InvalidFormError": (error) => new InvalidRequestError({ message: error.message, field: "fields" }),
-            }),
-          )
+          const created = yield* form
+            .create({
+              id: ctx.payload.id,
+              sessionID: ctx.params.sessionID,
+              title: ctx.payload.title,
+              metadata: ctx.payload.metadata,
+              fields: ctx.payload.fields,
+            })
+            .pipe(
+              Effect.catchTags({
+                "Form.AlreadyExistsError": (error) => new ConflictError({ resource: error.id, message: error.message }),
+                "Form.InvalidFormError": (error) =>
+                  new InvalidRequestError({ message: error.message, field: "fields" }),
+              }),
+            )
           return { data: created }
         }),
       )

+ 2 - 8
packages/tui/src/app.tsx

@@ -196,9 +196,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
   const api = OpenCode.make(options)
   const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
     Effect.map((response) => response.location.directory),
-    Effect.catch(() =>
-      Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)),
-    ),
+    Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
   )
   const reconnectEndpoint = input.server.reconnect
   const reconnect = reconnectEndpoint
@@ -411,11 +409,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
   })
 })
 
-function App(props: {
-  onSnapshot?: () => Promise<string[]>
-  pluginHost: TuiPluginHost
-  pair?: DialogPairCredentials
-}) {
+function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPluginHost; pair?: DialogPairCredentials }) {
   const log = useLog({ component: "app" })
   const startup = useTuiStartup()
   const tuiConfig = useTuiConfig()

+ 4 - 5
packages/tui/src/context/data.tsx

@@ -6,8 +6,7 @@
 import type {
   AgentInfo,
   CommandInfo,
-  FormFormInfo,
-  FormUrlInfo,
+  FormInfo,
   IntegrationInfo,
   LocationRef,
   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
 // server cannot recover their Location when settling them. Preserve the event Location
 // until MCP elicitations carry session ownership.
-export type FormInfo = (FormFormInfo | FormUrlInfo) & { readonly location?: LocationRef }
+export type FormWithLocation = FormInfo & { readonly location?: LocationRef }
 
 type LocationData = {
   agent?: AgentInfo[]
@@ -66,7 +65,7 @@ type Data = {
     input: Record<string, string[]>
     permission: Record<string, PermissionV2Request[]>
     // Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
-    form: Record<string, FormInfo[]>
+    form: Record<string, FormWithLocation[]>
   }
   project: {
     permission: Record<string, PermissionSavedInfo[]>
@@ -1033,7 +1032,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             directory: response.location.directory,
             workspaceID: response.location.workspaceID,
           }
-          const forms = response.data.reduce<Record<string, FormInfo[]>>(
+          const forms = response.data.reduce<Record<string, FormWithLocation[]>>(
             (result, form) => ({
               ...result,
               [form.sessionID]: [

+ 303 - 244
packages/tui/src/routes/session/form.tsx

@@ -4,19 +4,25 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid"
 import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
 import open from "open"
 import { selectedForeground, tint, useTheme } from "../../context/theme"
-import type { FormFormInfo, FormValue } from "@opencode-ai/sdk/v2"
-import type { FormInfo } from "../../context/data"
+import type { FormField, FormValue } from "@opencode-ai/sdk/v2"
+import type { FormWithLocation } from "../../context/data"
 import { useSDK } from "../../context/sdk"
+import { useClipboard } from "../../context/clipboard"
 import { SplitBorder } from "../../ui/border"
+import { useToast } from "../../ui/toast"
 import { useTuiConfig } from "../../config"
 import { useBindings, useOpencodeModeStack } from "../../keymap"
 
 const FORM_MODE = "form"
 
-type Field = FormFormInfo["fields"][number]
+type Field = Exclude<FormField, { type: "external" }>
 
-function fieldLabel(field: Field) {
-  return field.title ?? field.key
+function isField(field: FormField): field is Field {
+  return field.type !== "external"
+}
+
+function fieldLabel(field: FormField) {
+  return field.title ?? (field.type === "external" ? field.url : field.key)
 }
 
 function truncate(label: string, max: number) {
@@ -24,7 +30,7 @@ function truncate(label: string, max: number) {
 }
 
 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)
     return `Must be at least ${field.minLength} characters`
   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)"
   }
   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) {
-  if (field.type !== "multiselect" || value === undefined) return
+function validateSelection(field: Field, value: FormValue | undefined): string | undefined {
+  if (field.type !== "multiselect" || value === undefined) return undefined
   if (!Array.isArray(value)) return "Expected selections"
   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.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 (field.required && (value === "" || (Array.isArray(value) && value.length === 0))) {
     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)) {
       return "Select an available option"
     }
-    return
+    return undefined
   }
   if (field.type === "number" || field.type === "integer") {
     if (typeof value !== "number" || !Number.isFinite(value)) return "Expected a number"
     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.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"
   const invalid = validateSelection(field, value)
@@ -91,6 +99,7 @@ function validateValue(field: Field, value: FormValue | undefined) {
   ) {
     return "Select only available options"
   }
+  return undefined
 }
 
 function fieldRows(field: Field): { value: FormValue; label: string; description?: string }[] {
@@ -117,11 +126,6 @@ function selectedRow(field: Field | undefined, value: FormValue | undefined) {
   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) {
   if (value === undefined) return ""
   const label = (item: string | number | boolean) =>
@@ -130,7 +134,7 @@ function display(field: Field, value: FormValue | undefined) {
   return label(value)
 }
 
-function requestOptions(form: FormInfo) {
+function requestOptions(form: FormWithLocation) {
   if (form.sessionID !== "global" || !form.location) return undefined
   return {
     headers: {
@@ -140,107 +144,32 @@ function requestOptions(form: FormInfo) {
   }
 }
 
-export function FormPrompt(props: { form: FormInfo }) {
-  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" } }) {
+export function FormPrompt(props: { form: FormWithLocation }) {
   const sdk = useSDK()
   const { theme } = useTheme()
   const renderer = useRenderer()
   const dimensions = useTerminalDimensions()
   const tuiConfig = useTuiConfig()
   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 [store, setStore] = createStore({
     tab: 0,
     answers: Object.fromEntries(
-      props.form.fields.flatMap((field) => (field.default === undefined ? [] : [[field.key, field.default]])),
+      configuredFields.flatMap((field) => (field.default === undefined ? [] : [[field.key, field.default]])),
     ) as Record<string, FormValue | undefined>,
     custom: Object.fromEntries(
-      props.form.fields.flatMap((field) => {
-        const value = customDefault(field)
-        return value === undefined ? [] : [[field.key, value]]
+      configuredFields.flatMap((field) => {
+        if (field.type !== "string" || !field.options || !field.custom || typeof field.default !== "string") return []
+        if (field.options.some((option) => option.value === field.default)) return []
+        return [[field.key, field.default]]
       }),
     ) 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,
     error: "",
   })
@@ -248,9 +177,14 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
   let textarea: TextareaRenderable | undefined
   let review: ScrollBoxRenderable | undefined
 
+  const message = createMemo(() => {
+    const value = props.form.metadata?.["message"]
+    return typeof value === "string" ? value : undefined
+  })
   const fields = createMemo(() => {
     const answers: Record<string, FormValue | undefined> = {}
     return props.form.fields.filter((field) => {
+      if (field.type === "external") return true
       const active = (field.when ?? []).every((when) => {
         const value = answers[when.key]
         if (value === undefined) return false
@@ -263,9 +197,9 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
   })
   const single = createMemo(() => {
     const list = fields()
-    if (props.form.fields.length !== 1) return false
     if (list.length !== 1) return false
-    const field = list[0]!
+    const field = list[0]
+    if (field.type === "external") return false
     return field.type === "boolean" || (field.type === "string" && field.options !== undefined)
   })
   const tabs = createMemo(() => (single() ? 1 : fields().length + 1))
@@ -280,10 +214,18 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
         return value !== undefined
       }).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 rows = createMemo(() => {
-    const current = field()
+    const current = answerField()
     if (!current) return []
     const configured = fieldRows(current)
     const value = store.answers[current.key]
@@ -296,21 +238,32 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
   })
   const textual = createMemo(() => {
     if (confirm()) return false
-    const current = field()
+    const current = answerField()
     if (!current) return false
     if (current.type === "number" || current.type === "integer") return true
     return current.type === "string" && current.options === undefined
   })
   const custom = createMemo(() => {
-    const current = field()
+    const current = answerField()
     if (!current) return false
     if (current.type === "string" && current.options !== undefined) return current.custom === true
     if (current.type === "multiselect") return current.custom === true
     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 current = field()
+    const current = answerField()
     if (current?.type === "string") {
       if (current.placeholder) return current.placeholder
       if (current.format === "email") return "name@example.com"
@@ -328,11 +281,11 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
     return "Type your answer"
   })
   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 value = input()
     if (!value) return false
-    const answer = store.answers[field()?.key ?? ""]
+    const answer = store.answers[answerField()?.key ?? ""]
     if (Array.isArray(answer)) return answer.includes(value)
     return answer === value
   })
@@ -363,7 +316,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
   }
 
   function pick(value: FormValue, customValue?: string) {
-    const current = field()
+    const current = answerField()
     if (!current) return
     const invalid = validateValue(current, value)
     if (invalid) {
@@ -380,7 +333,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
   }
 
   function toggle(value: string) {
-    const current = field()
+    const current = answerField()
     if (!current) return
     const existing = store.answers[current.key]
     const list = Array.isArray(existing) ? [...existing] : []
@@ -392,7 +345,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
 
   function validateCurrent() {
     if (confirm()) return true
-    const current = field()
+    const current = answerField()
     if (!current) return true
     const invalid = validateValue(current, store.answers[current.key])
     if (!invalid) return true
@@ -404,7 +357,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
     if (!confirm() && index > store.tab && !validateCurrent()) return
     const next = fields()[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("error", "")
   }
@@ -433,7 +386,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
   }
 
   function commitInput(text: string) {
-    const current = field()
+    const current = answerField()
     if (!current) return false
     const isTextual = textual()
     const isMulti = multi()
@@ -507,9 +460,9 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
     if (!single()) selectTab((store.tab + direction + tabs()) % tabs())
   }
 
-  function selectTabFromMouse(target?: Field) {
+  function selectTabFromMouse(target?: FormField) {
     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
     }
     const move = () => selectTab(targetIndex())
@@ -524,6 +477,83 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
     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)))
 
   useBindings(() => ({
@@ -585,7 +615,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
         group: "Form",
         cmd: () => {
           const text = textarea?.plainText?.trim() ?? ""
-          const current = field()
+          const current = answerField()
           if (!current) return
           if (textual()) {
             submitInput(text)
@@ -606,6 +636,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
   useBindings(() => {
     const total = rows().length + (custom() ? 1 : 0)
     const max = Math.min(total, 9)
+    const external = externalField()
 
     return {
       mode: FORM_MODE,
@@ -615,12 +646,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
           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),
-            )
-          },
+          run: cancel,
         },
       ],
       bindings: [
@@ -650,110 +676,86 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
           group: "Form",
           cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
         },
-        ...(confirm()
+        ...(external
           ? [
               {
                 key: "return",
-                desc: "Submit form",
-                group: "Form",
-                cmd: () => {
-                  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",
-                desc: "Dismiss form",
+                desc:
+                  store.answers[external.key] === true
+                    ? "Continue"
+                    : store.externalReady[external.key]
+                      ? "Confirm completion"
+                      : "Open link",
                 group: "Form",
-                cmd: () => {
-                  void sdk.api.form.cancel(
-                    { sessionID: props.form.sessionID, formID: props.form.id },
-                    requestOptions(props.form),
-                  )
-                },
+                cmd: acknowledgeExternal,
               },
-              { key: "up", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) },
-              { key: "k", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) },
-              { key: "down", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) },
-              { key: "j", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) },
+              { key: "c", desc: "Copy link", group: "Form", cmd: copyExternal },
+              { key: "escape", desc: "Dismiss form", group: "Form", cmd: cancel },
               ...tuiConfig.keybinds.get("app.exit"),
             ]
-          : [
-              ...Array.from({ length: max }, (_, index) => ({
-                key: String(index + 1),
-                desc: `Select answer ${index + 1}`,
-                group: "Form",
-                cmd: () => {
-                  setStore("selected", index)
-                  selectOption()
+          : confirm()
+            ? [
+                {
+                  key: "return",
+                  desc: "Submit form",
+                  group: "Form",
+                  cmd: submit,
                 },
-              })),
-              {
-                key: "up",
-                desc: "Previous answer",
-                group: "Form",
-                cmd: () => setStore("selected", (store.selected - 1 + total) % total),
-              },
-              {
-                key: "k",
-                desc: "Previous answer",
-                group: "Form",
-                cmd: () => setStore("selected", (store.selected - 1 + total) % total),
-              },
-              {
-                key: "down",
-                desc: "Next answer",
-                group: "Form",
-                cmd: () => setStore("selected", (store.selected + 1) % total),
-              },
-              {
-                key: "j",
-                desc: "Next answer",
-                group: "Form",
-                cmd: () => setStore("selected", (store.selected + 1) % total),
-              },
-              { key: "return", desc: "Select answer", group: "Form", cmd: () => selectOption() },
-              {
-                key: "escape",
-                desc: "Dismiss form",
-                group: "Form",
-                cmd: () => {
-                  void sdk.api.form.cancel(
-                    { sessionID: props.form.sessionID, formID: props.form.id },
-                    requestOptions(props.form),
-                  )
+                {
+                  key: "escape",
+                  desc: "Dismiss form",
+                  group: "Form",
+                  cmd: cancel,
                 },
-              },
-              ...tuiConfig.keybinds.get("app.exit"),
-            ]),
+                { key: "up", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) },
+                { key: "k", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) },
+                { key: "down", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) },
+                { key: "j", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) },
+                ...tuiConfig.keybinds.get("app.exit"),
+              ]
+            : [
+                ...Array.from({ length: max }, (_, index) => ({
+                  key: String(index + 1),
+                  desc: `Select answer ${index + 1}`,
+                  group: "Form",
+                  cmd: () => {
+                    setStore("selected", index)
+                    selectOption()
+                  },
+                })),
+                {
+                  key: "up",
+                  desc: "Previous answer",
+                  group: "Form",
+                  cmd: () => setStore("selected", (store.selected - 1 + total) % total),
+                },
+                {
+                  key: "k",
+                  desc: "Previous answer",
+                  group: "Form",
+                  cmd: () => setStore("selected", (store.selected - 1 + total) % total),
+                },
+                {
+                  key: "down",
+                  desc: "Next answer",
+                  group: "Form",
+                  cmd: () => setStore("selected", (store.selected + 1) % total),
+                },
+                {
+                  key: "j",
+                  desc: "Next answer",
+                  group: "Form",
+                  cmd: () => setStore("selected", (store.selected + 1) % total),
+                },
+                { key: "return", desc: "Select answer", group: "Form", cmd: () => selectOption() },
+                {
+                  key: "escape",
+                  desc: "Dismiss form",
+                  group: "Form",
+                  cmd: cancel,
+                },
+                ...tuiConfig.keybinds.get("app.exit"),
+              ]),
       ],
     }
   })
@@ -769,14 +771,21 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
         <box paddingLeft={1}>
           <text fg={theme.textMuted}>{props.form.title}</text>
         </box>
+        <Show when={message()}>
+          <box paddingLeft={1}>
+            <text fg={theme.text}>{message()}</text>
+          </box>
+        </Show>
         <Show when={!single() && !tabbed()}>
           <box flexDirection="row" gap={1} paddingLeft={1}>
             <text fg={theme.textMuted}>
               {confirm() ? "Review" : `Field ${Math.min(store.tab, fields().length - 1) + 1} of ${fields().length}`}
             </text>
-            <text fg={theme.textMuted}>
-              · {answered()}/{fields().length} answered
-            </text>
+            <Show when={fields().length > 0}>
+              <text fg={theme.textMuted}>
+                · {answered()}/{fields().length} completed
+              </text>
+            </Show>
           </box>
         </Show>
         <Show when={!single() && tabbed()}>
@@ -828,16 +837,45 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
           </box>
         </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>
               <text fg={theme.text}>
-                {field()!.description ?? fieldLabel(field()!)}
-                {field()!.required ? " (required)" : ""}
+                {answerField()!.description ?? fieldLabel(answerField()!)}
+                {answerField()!.required ? " (required)" : ""}
                 {multi() ? " (select all that apply)" : ""}
               </text>
             </box>
-            <Show when={textual() ? field()!.key : undefined} keyed>
+            <Show when={textual() ? answerField()!.key : undefined} keyed>
               <box paddingLeft={1}>
                 <textarea
                   ref={(val: TextareaRenderable) => {
@@ -848,7 +886,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
                       val.gotoLineEnd()
                     })
                   }}
-                  initialValue={input() || display(field()!, store.answers[field()!.key])}
+                  initialValue={input() || display(answerField()!, store.answers[answerField()!.key])}
                   placeholder={placeholder()}
                   placeholderColor={theme.textMuted}
                   minHeight={1}
@@ -865,7 +903,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
                   {(row, i) => {
                     const active = () => i() === store.selected
                     const picked = () => {
-                      const value = store.answers[field()?.key ?? ""]
+                      const value = store.answers[answerField()?.key ?? ""]
                       if (Array.isArray(value)) return value.includes(String(row.value))
                       return value === row.value
                     }
@@ -973,11 +1011,21 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
           >
             <For each={fields()}>
               {(item) => {
-                const value = () => display(item, store.answers[item.key])
-                const answered = () => {
-                  const value = store.answers[item.key]
-                  return value !== undefined
+                if (item.type === "external") {
+                  const acknowledged = () => store.answers[item.key] === true
+                  return (
+                    <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 invalid = () => validateValue(item, store.answers[item.key])
                 return (
@@ -985,7 +1033,9 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
                     <text>
                       <span style={{ fg: theme.textMuted }}>{truncate(fieldLabel(item), 40)}:</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)")}
                       </span>
@@ -1012,23 +1062,32 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) {
               {"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
             </text>
           </Show>
-          <Show when={!confirm() && !textual()}>
+          <Show when={!confirm() && !textual() && !externalField()}>
             <text fg={theme.text}>
               {"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
             </text>
           </Show>
-          <Show when={confirm()}>
+          <Show when={confirm() && fields().length > 0}>
             <text fg={theme.text}>
               {"↑↓"} <span style={{ fg: theme.textMuted }}>scroll</span>
             </text>
           </Show>
-          <text fg={theme.text}>
-            enter{" "}
-            <span style={{ fg: theme.textMuted }}>
-              {confirm() ? "submit" : multi() ? "toggle" : single() ? "submit" : "confirm"}
-            </span>
+          <text
+            fg={theme.text}
+            onMouseUp={() => {
+              if (renderer.getSelection()?.getSelectedText()) return
+              if (confirm()) submit()
+              if (externalField()) acknowledgeExternal()
+            }}
+          >
+            enter <span style={{ fg: theme.textMuted }}>{actionLabel()}</span>
           </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>
           </text>
         </box>

+ 2 - 6
packages/tui/test/cli/cmd/tui/notifications.test.ts

@@ -77,16 +77,12 @@ function question(id: string, sessionID = "session"): QuestionRequest {
   }
 }
 
-function form(
-  id: string,
-  sessionID = "session",
-): Extract<OpenCodeEvent, { type: "form.created" }>["data"]["form"] {
+function form(id: string, sessionID = "session"): Extract<OpenCodeEvent, { type: "form.created" }>["data"]["form"] {
   return {
     id,
     sessionID,
     title: "Input requested",
-    mode: "form",
-    fields: [],
+    fields: [{ key: "authorization", type: "external", url: "https://example.com" }],
   }
 }
 

+ 19 - 14
packages/tui/test/cli/tui/data.test.tsx

@@ -12,6 +12,14 @@ import { createSessionRows, type SessionRow } from "../../../src/routes/session/
 import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
 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) {
   const start = Date.now()
   while (!fn()) {
@@ -1510,7 +1518,7 @@ test("adds, dismisses, and refreshes form requests", async () => {
   const calls = createFetch((url) => {
     if (url.pathname !== "/api/session/ses_1/form") return
     return json({
-      data: [{ id: "frm_remote", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] }],
+      data: [{ id: "frm_remote", sessionID: "ses_1", title: "Input requested", fields: formFields }],
     })
   }, events)
   let data!: ReturnType<typeof useData>
@@ -1540,13 +1548,13 @@ test("adds, dismisses, and refreshes form requests", async () => {
       id: "evt_form_created_1",
       created: 0,
       type: "form.created",
-      data: { form: { id: "frm_1", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] } },
+      data: { form: { id: "frm_1", sessionID: "ses_1", title: "Input requested", fields: formFields } },
     })
     emitEvent(events, {
       id: "evt_form_created_duplicate",
       created: 1,
       type: "form.created",
-      data: { form: { id: "frm_1", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] } },
+      data: { form: { id: "frm_1", sessionID: "ses_1", title: "Input requested", fields: formFields } },
     })
     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",
       created: 3,
       type: "form.created",
-      data: { form: { id: "frm_2", sessionID: "ses_1", title: "Input requested", mode: "form", fields: [] } },
+      data: { form: { id: "frm_2", sessionID: "ses_1", title: "Input requested", fields: formFields } },
     })
     emitEvent(events, {
       id: "evt_form_cancelled_2",
@@ -1612,7 +1620,7 @@ test("tracks global forms by location", async () => {
       location: other,
       type: "form.created",
       data: {
-        form: { id: "frm_other", sessionID: "global", title: "Input requested", mode: "form", fields: [] },
+        form: { id: "frm_other", sessionID: "global", title: "Input requested", fields: formFields },
       },
     })
 
@@ -1625,7 +1633,7 @@ test("tracks global forms by location", async () => {
       location: { directory },
       type: "form.created",
       data: {
-        form: { id: "frm_default", sessionID: "global", title: "Input requested", mode: "form", fields: [] },
+        form: { id: "frm_default", sessionID: "global", title: "Input requested", fields: formFields },
       },
     })
     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",
           sessionID: "global",
           title: "Input requested",
-          mode: "form",
-          fields: [],
+          fields: formFields,
         },
       ],
     })
@@ -1743,8 +1750,7 @@ test("refreshes global forms once per loaded location after reconnect", async ()
           id: `frm_${requestedDirectory === other.directory ? "other" : "default"}_${count}`,
           sessionID: "global",
           title: "Input requested",
-          mode: "form",
-          fields: [],
+          fields: formFields,
         },
       ],
     })
@@ -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 () => {
   const events = createEventStream()
   let requests = [
-    { id: "frm_old", sessionID: "ses_old", title: "Input requested", mode: "form" as const, fields: [] },
+    { id: "frm_old", sessionID: "ses_old", title: "Input requested", fields: formFields },
     {
       id: "frm_keep",
       sessionID: "ses_keep",
       title: "Input requested",
-      mode: "url" as const,
-      url: "https://example.com",
+      fields: [{ key: "authorization", type: "external" as const, url: "https://example.com" }],
     },
   ]
   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")
     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()
 
     await wait(() => calls === 2 && data.session.form.list("ses_new")?.[0]?.id === "frm_new")

+ 139 - 0
packages/tui/test/cli/tui/form.test.tsx

@@ -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()
+  }
+})

+ 10 - 6
packages/tui/test/fixture/tui-sdk.ts

@@ -5,9 +5,11 @@ export const worktree = "/tmp/opencode"
 export const directory = `${worktree}/packages/tui`
 
 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), {
     ...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>) {
   const session = [] as URL[]
-  const fetch = (async (input: RequestInfo | URL) => {
-    const url = new URL(input instanceof Request ? input.url : String(input))
+  async function fetch(input: RequestInfo | URL, init?: RequestInit) {
+    const request = input instanceof Request ? input : new Request(input, init)
+    const url = new URL(request.url)
     if (url.pathname === "/session") session.push(url)
-    const overridden = await override?.(url)
+    const overridden = await override?.(url, request)
     if (overridden) return overridden
     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 === "/vcs") return json({ branch: "main" })
     throw new Error(`unexpected request: ${url.pathname}`)
-  }) as typeof globalThis.fetch
+  }
+  fetch.preconnect = () => {}
   return { fetch, session }
 }
 

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff