refactor(form): rename link fields to external

This commit is contained in:
Aiden Cline 2026-07-09 18:53:58 -05:00
parent 08ff181df6
commit ad0227c9dd
10 changed files with 84 additions and 80 deletions

View file

@ -2510,7 +2510,7 @@ export type FormRequestListOutput = {
custom?: boolean
default?: Array<string>
}
| { type: "link"; url: string; title?: string; description?: string }
| { type: "external"; url: string; title?: string; description?: string }
),
...Array<
| {
@ -2593,7 +2593,7 @@ export type FormRequestListOutput = {
custom?: boolean
default?: Array<string>
}
| { type: "link"; url: string; title?: string; description?: string }
| { type: "external"; url: string; title?: string; description?: string }
>,
]
}>
@ -2689,7 +2689,7 @@ export type FormListOutput = {
custom?: boolean
default?: Array<string>
}
| { type: "link"; url: string; title?: string; description?: string }
| { type: "external"; url: string; title?: string; description?: string }
),
...Array<
| {
@ -2772,7 +2772,7 @@ export type FormListOutput = {
custom?: boolean
default?: Array<string>
}
| { type: "link"; url: string; title?: string; description?: string }
| { type: "external"; url: string; title?: string; description?: string }
>,
]
}>
@ -2874,7 +2874,7 @@ export type FormCreateInput = {
readonly custom?: boolean
readonly default?: ReadonlyArray<string>
}
| { readonly type: "link"; readonly url: string; readonly title?: string; readonly description?: string }
| { readonly type: "external"; readonly url: string; readonly title?: string; readonly description?: string }
),
...Array<
| {
@ -2965,7 +2965,7 @@ export type FormCreateInput = {
readonly custom?: boolean
readonly default?: ReadonlyArray<string>
}
| { readonly type: "link"; readonly url: string; readonly title?: string; readonly description?: string }
| { readonly type: "external"; readonly url: string; readonly title?: string; readonly description?: string }
>,
]
}["id"]
@ -3063,7 +3063,7 @@ export type FormCreateInput = {
readonly custom?: boolean
readonly default?: ReadonlyArray<string>
}
| { readonly type: "link"; readonly url: string; readonly title?: string; readonly description?: string }
| { readonly type: "external"; readonly url: string; readonly title?: string; readonly description?: string }
),
...Array<
| {
@ -3154,7 +3154,7 @@ export type FormCreateInput = {
readonly custom?: boolean
readonly default?: ReadonlyArray<string>
}
| { readonly type: "link"; readonly url: string; readonly title?: string; readonly description?: string }
| { readonly type: "external"; readonly url: string; readonly title?: string; readonly description?: string }
>,
]
}["title"]
@ -3252,7 +3252,7 @@ export type FormCreateInput = {
readonly custom?: boolean
readonly default?: ReadonlyArray<string>
}
| { readonly type: "link"; readonly url: string; readonly title?: string; readonly description?: string }
| { readonly type: "external"; readonly url: string; readonly title?: string; readonly description?: string }
),
...Array<
| {
@ -3343,7 +3343,7 @@ export type FormCreateInput = {
readonly custom?: boolean
readonly default?: ReadonlyArray<string>
}
| { readonly type: "link"; readonly url: string; readonly title?: string; readonly description?: string }
| { readonly type: "external"; readonly url: string; readonly title?: string; readonly description?: string }
>,
]
}["metadata"]
@ -3441,7 +3441,7 @@ export type FormCreateInput = {
readonly custom?: boolean
readonly default?: ReadonlyArray<string>
}
| { readonly type: "link"; readonly url: string; readonly title?: string; readonly description?: string }
| { readonly type: "external"; readonly url: string; readonly title?: string; readonly description?: string }
),
...Array<
| {
@ -3532,7 +3532,7 @@ export type FormCreateInput = {
readonly custom?: boolean
readonly default?: ReadonlyArray<string>
}
| { readonly type: "link"; readonly url: string; readonly title?: string; readonly description?: string }
| { readonly type: "external"; readonly url: string; readonly title?: string; readonly description?: string }
>,
]
}["fields"]
@ -3626,7 +3626,7 @@ export type FormCreateOutput = {
custom?: boolean
default?: Array<string>
}
| { type: "link"; url: string; title?: string; description?: string }
| { type: "external"; url: string; title?: string; description?: string }
),
...Array<
| {
@ -3709,7 +3709,7 @@ export type FormCreateOutput = {
custom?: boolean
default?: Array<string>
}
| { type: "link"; url: string; title?: string; description?: string }
| { type: "external"; url: string; title?: string; description?: string }
>,
]
}
@ -3808,7 +3808,7 @@ export type FormGetOutput = {
custom?: boolean
default?: Array<string>
}
| { type: "link"; url: string; title?: string; description?: string }
| { type: "external"; url: string; title?: string; description?: string }
),
...Array<
| {
@ -3891,7 +3891,7 @@ export type FormGetOutput = {
custom?: boolean
default?: Array<string>
}
| { type: "link"; url: string; title?: string; description?: string }
| { type: "external"; url: string; title?: string; description?: string }
>,
]
}
@ -5429,7 +5429,7 @@ export type EventSubscribeOutput =
custom?: boolean
default?: Array<string>
}
| { type: "link"; url: string; title?: string; description?: string }
| { type: "external"; url: string; title?: string; description?: string }
),
...Array<
| {
@ -5492,7 +5492,7 @@ export type EventSubscribeOutput =
custom?: boolean
default?: Array<string>
}
| { type: "link"; url: string; title?: string; description?: string }
| { type: "external"; url: string; title?: string; description?: string }
>,
]
}

View file

@ -224,12 +224,14 @@ export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
function validateAnswer(form: Info, answer: Answer) {
const fields = new Map(form.fields.flatMap((field) => (field.type === "link" ? [] : [[field.key, field] as const])))
const fields = new Map(
form.fields.flatMap((field) => (field.type === "external" ? [] : [[field.key, field] as const])),
)
for (const key of Object.keys(answer)) {
if (!fields.has(key)) return `Unknown form field: ${key}`
}
for (const field of form.fields) {
if (field.type === "link") continue
if (field.type === "external") continue
const value = answer[field.key]
const active = isActive(field, answer)
if (value === undefined) {
@ -242,7 +244,7 @@ function validateAnswer(form: Info, answer: Answer) {
}
}
type InputField = Exclude<Form.Field, Form.LinkField>
type InputField = Exclude<Form.Field, Form.ExternalField>
function isActive(field: InputField, answer: Answer) {
if (!field.when) return true
@ -265,7 +267,7 @@ function validateFields(fields: ReadonlyArray<Form.Field>) {
if (fields.length === 0) return "Form must have at least one field"
const earlier = new Map<string, InputField>()
for (const field of fields) {
if (field.type === "link") continue
if (field.type === "external") continue
if (earlier.has(field.key)) return `Duplicate form field key: ${field.key}`
for (const when of field.when ?? []) {
const target = earlier.get(when.key)

View file

@ -311,7 +311,7 @@ export const layer = Layer.effect(
elicitationID: input.params.elicitationId,
message: input.params.message,
},
fields: [{ type: "link", url: input.params.url }],
fields: [{ type: "external", url: input.params.url }],
})
.pipe(
Effect.raceFirst(waitForAbort(input.signal)),

View file

@ -58,13 +58,13 @@ describe("Form", () => {
yield* service.reply({ id: created.id, answer: { name: "Ava" } })
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { name: "Ava" } })
const linkOnly = yield* service.create({
const externalOnly = yield* service.create({
sessionID: "global",
title: "External setup",
fields: [{ type: "link", url: "https://example.com/setup" }],
fields: [{ type: "external", url: "https://example.com/setup" }],
})
yield* service.reply({ id: linkOnly.id, answer: {} })
expect(yield* service.state(linkOnly.id)).toEqual({ status: "answered", answer: {} })
yield* service.reply({ id: externalOnly.id, answer: {} })
expect(yield* service.state(externalOnly.id)).toEqual({ status: "answered", answer: {} })
}),
)
@ -267,14 +267,14 @@ describe("Form", () => {
}),
)
it.effect("treats link fields as non-answerable", () =>
it.effect("treats external fields as non-answerable", () =>
Effect.gen(function* () {
const service = yield* Form.Service
const created = yield* service.create({
sessionID: "global",
title: "External setup",
fields: [
{ type: "link", url: "https://example.com/setup", title: "Open setup" },
{ type: "external", url: "https://example.com/setup", title: "Open setup" },
{ key: "name", type: "string", required: true },
],
})

View file

@ -94,13 +94,13 @@ export const MultiselectField = Schema.Struct({
}).annotate({ identifier: "Form.MultiselectField" })
export interface MultiselectField extends Schema.Schema.Type<typeof MultiselectField> {}
export const LinkField = Schema.Struct({
type: Schema.Literal("link"),
export const ExternalField = Schema.Struct({
type: Schema.Literal("external"),
url: Schema.String,
title: Schema.String.pipe(optional),
description: Schema.String.pipe(optional),
}).annotate({ identifier: "Form.LinkField" })
export interface LinkField extends Schema.Schema.Type<typeof LinkField> {}
}).annotate({ identifier: "Form.ExternalField" })
export interface ExternalField extends Schema.Schema.Type<typeof ExternalField> {}
export const Field = Schema.Union([
StringField,
@ -108,9 +108,9 @@ export const Field = Schema.Union([
IntegerField,
BooleanField,
MultiselectField,
LinkField,
ExternalField,
]).pipe(Schema.toTaggedUnion("type"), Schema.annotate({ identifier: "Form.Field" }))
export type Field = StringField | NumberField | IntegerField | BooleanField | MultiselectField | LinkField
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

View file

@ -61,8 +61,8 @@ describe("contract hygiene", () => {
Schema.decodeUnknownSync(Form.Info)({
id: Form.ID.create(),
sessionID: "global",
title: "Link form",
fields: [{ type: "link", url: "https://example.com" }],
title: "External form",
fields: [{ type: "external", url: "https://example.com" }],
}).fields,
).toHaveLength(1)
})
@ -92,7 +92,7 @@ describe("contract hygiene", () => {
Form.Field,
Form.Fields,
Form.Info,
Form.LinkField,
Form.ExternalField,
Mcp.Resource,
Mcp.ResourceTemplate,
Mcp.ResourceCatalog,

View file

@ -3545,8 +3545,8 @@ export type FormMultiselectField = {
default?: Array<string>
}
export type FormLinkField = {
type: "link"
export type FormExternalField = {
type: "external"
url: string
title?: string
description?: string
@ -3558,7 +3558,7 @@ export type FormField =
| FormIntegerField
| FormBooleanField
| FormMultiselectField
| FormLinkField
| FormExternalField
export type FormFields = Array<FormField>
@ -10622,7 +10622,7 @@ export type FormField1 =
| FormIntegerField1
| FormBooleanField1
| FormMultiselectField1
| FormLinkField
| FormExternalField
export type FormFields1 = [FormField1, FormField1]

View file

@ -15,19 +15,19 @@ import { useBindings, useOpencodeModeStack } from "../../keymap"
const FORM_MODE = "form"
type Field = Exclude<FormField, { type: "link" }>
type LinkField = Extract<FormField, { type: "link" }>
type Field = Exclude<FormField, { type: "external" }>
type ExternalField = Extract<FormField, { type: "external" }>
function isField(field: FormField): field is Field {
return field.type !== "link"
return field.type !== "external"
}
function isLink(field: FormField): field is LinkField {
return field.type === "link"
function isExternal(field: FormField): field is ExternalField {
return field.type === "external"
}
function fieldLabel(field: FormField) {
return field.title ?? (field.type === "link" ? field.url : field.key)
return field.title ?? (field.type === "external" ? field.url : field.key)
}
function truncate(label: string, max: number) {
@ -193,7 +193,7 @@ function FieldsPrompt(props: { form: FormInfo }) {
const fields = createMemo(() => {
const answers: Record<string, FormValue | undefined> = {}
return props.form.fields.filter((field) => {
if (field.type === "link") return true
if (field.type === "external") return true
const active = (field.when ?? []).every((when) => {
const value = answers[when.key]
if (value === undefined) return false
@ -208,7 +208,7 @@ function FieldsPrompt(props: { form: FormInfo }) {
const list = fields()
if (list.length !== 1) return false
const field = list[0]!
if (field.type === "link") return false
if (field.type === "external") return false
return field.type === "boolean" || (field.type === "string" && field.options !== undefined)
})
const answerable = createMemo(() => fields().filter(isField))
@ -229,9 +229,9 @@ function FieldsPrompt(props: { form: FormInfo }) {
const current = field()
return current && isField(current) ? current : undefined
})
const linkField = createMemo(() => {
const externalField = createMemo(() => {
const current = field()
return current && isLink(current) ? current : undefined
return current && isExternal(current) ? current : undefined
})
const confirm = createMemo(() => !single() && store.tab >= fields().length)
const rows = createMemo(() => {
@ -263,7 +263,7 @@ function FieldsPrompt(props: { form: FormInfo }) {
const multi = createMemo(() => answerField()?.type === "multiselect")
const actionLabel = createMemo(() => {
if (confirm()) return answerable().length === 0 ? "I finished" : "submit"
if (linkField()) return "open link"
if (externalField()) return "open link"
if (multi()) return "toggle"
if (single()) return "submit"
return "confirm"
@ -487,8 +487,8 @@ function FieldsPrompt(props: { form: FormInfo }) {
void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form))
}
function openLink() {
const current = linkField()
function openExternal() {
const current = externalField()
if (!current) return
const index = store.tab
setStore("error", "")
@ -497,8 +497,8 @@ function FieldsPrompt(props: { form: FormInfo }) {
.catch(() => setStore("error", "Could not open the browser. Copy the URL and continue manually."))
}
function copyLink() {
const current = linkField()
function copyExternal() {
const current = externalField()
if (!current || !clipboard.write) return
void clipboard
.write(current.url)
@ -657,10 +657,10 @@ function FieldsPrompt(props: { form: FormInfo }) {
group: "Form",
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
},
...(linkField()
...(externalField()
? [
{ key: "return", desc: "Open link", group: "Form", cmd: openLink },
{ key: "c", desc: "Copy link", group: "Form", cmd: copyLink },
{ key: "return", desc: "Open link", group: "Form", cmd: openExternal },
{ key: "c", desc: "Copy link", group: "Form", cmd: copyExternal },
{ key: "escape", desc: "Dismiss form", group: "Form", cmd: cancel },
...tuiConfig.keybinds.get("app.exit"),
]
@ -764,7 +764,7 @@ function FieldsPrompt(props: { form: FormInfo }) {
<For each={fields()}>
{(item, index) => {
const isTab = () => index() === store.tab
const isAnswered = () => item.type !== "link" && store.answers[item.key] !== undefined
const isAnswered = () => item.type !== "external" && store.answers[item.key] !== undefined
return (
<box
paddingLeft={1}
@ -808,23 +808,23 @@ function FieldsPrompt(props: { form: FormInfo }) {
</box>
</Show>
<Show when={!confirm() && linkField()}>
{(link) => (
<Show when={!confirm() && externalField()}>
{(external) => (
<box paddingLeft={1} gap={1}>
<Show when={link().title}>
<text fg={theme.text}>{link().title}</text>
<Show when={external().title}>
<text fg={theme.text}>{external().title}</text>
</Show>
<Show when={link().description}>
<text fg={theme.textMuted}>{link().description}</text>
<Show when={external().description}>
<text fg={theme.textMuted}>{external().description}</text>
</Show>
<text
fg={theme.primary}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
openLink()
openExternal()
}}
>
{link().url}
{external().url}
</text>
</box>
)}
@ -1025,7 +1025,7 @@ function FieldsPrompt(props: { form: FormInfo }) {
{"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
</text>
</Show>
<Show when={!confirm() && !textual() && !linkField()}>
<Show when={!confirm() && !textual() && !externalField()}>
<text fg={theme.text}>
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
@ -1040,13 +1040,13 @@ function FieldsPrompt(props: { form: FormInfo }) {
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
if (confirm()) submit()
if (linkField()) openLink()
if (externalField()) openExternal()
}}
>
enter <span style={{ fg: theme.textMuted }}>{actionLabel()}</span>
</text>
<Show when={linkField() && clipboard.write}>
<text fg={theme.text} onMouseUp={copyLink}>
<Show when={externalField() && clipboard.write}>
<text fg={theme.text} onMouseUp={copyExternal}>
c <span style={{ fg: theme.textMuted }}>copy</span>
</text>
</Show>

View file

@ -77,15 +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",
fields: [{ type: "link", url: "https://example.com" }],
fields: [{ type: "external", url: "https://example.com" }],
}
}

View file

@ -12,7 +12,12 @@ 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 = [{ type: "link", url: "https://example.com" }] satisfies [{ type: "link"; url: string }]
const formFields = [{ type: "external", url: "https://example.com" }] satisfies [
{
type: "external"
url: string
},
]
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
@ -1808,7 +1813,7 @@ test("reconciles all pending form requests when the event stream reconnects", as
id: "frm_keep",
sessionID: "ses_keep",
title: "Input requested",
fields: [{ type: "link" as const, url: "https://example.com" }],
fields: [{ type: "external" as const, url: "https://example.com" }],
},
]
let calls = 0