From ff736fbbc824a638e436efa8ac7e804019f35ba5 Mon Sep 17 00:00:00 2001 From: James Long <17031+jlongster@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:45:32 +0000 Subject: [PATCH] feat(tui): coalesce equivalent forms --- .../client/src/promise/generated/types.ts | 18 ++++- packages/core/src/form.ts | 1 + packages/core/src/tool/plugin/websearch.ts | 2 + packages/core/test/form.test.ts | 2 + packages/core/test/tool-websearch.test.ts | 2 + packages/schema/src/form.ts | 3 + packages/tui/src/routes/home.tsx | 4 +- packages/tui/src/routes/session/form.tsx | 80 +++++++++---------- packages/tui/src/routes/session/index.tsx | 4 +- packages/tui/test/cli/tui/form.test.tsx | 28 ++++++- 10 files changed, 94 insertions(+), 50 deletions(-) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 3bb88bdaaf..80afd42b1a 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1937,9 +1937,23 @@ export type IntegrationInfo = { connections: Array } -export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields } +export type FormInfo = { + id: string + sessionID: string + title: string + coalesce?: string + metadata?: FormMetadata + fields: FormFields +} -export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 } +export type FormInfo1 = { + id: string + sessionID: string + title: string + coalesce?: string + metadata?: FormMetadata1 + fields: FormFields1 +} export type SessionInputAdmitted = { id: string diff --git a/packages/core/src/form.ts b/packages/core/src/form.ts index 0a23ef3efe..9b58cd0a36 100644 --- a/packages/core/src/form.ts +++ b/packages/core/src/form.ts @@ -132,6 +132,7 @@ export const layer = Layer.effect( id, sessionID: input.sessionID, title: input.title, + ...(input.coalesce === undefined ? {} : { coalesce: input.coalesce }), ...(input.metadata === undefined ? {} : { metadata: input.metadata }), fields: input.fields, } diff --git a/packages/core/src/tool/plugin/websearch.ts b/packages/core/src/tool/plugin/websearch.ts index a3b241cc84..03a7f405f2 100644 --- a/packages/core/src/tool/plugin/websearch.ts +++ b/packages/core/src/tool/plugin/websearch.ts @@ -59,6 +59,7 @@ export const Plugin = { const response = yield* forms.ask({ sessionID: context.sessionID, title: "Web Search", + coalesce: `${context.messageID}:websearch-consent`, metadata: { kind: "websearch.provider" }, fields: [ { @@ -91,6 +92,7 @@ export const Plugin = { ? yield* forms.ask({ sessionID: context.sessionID, title: "Choose a web search provider", + coalesce: `${context.messageID}:websearch-provider`, metadata: { kind: "websearch.provider" }, fields: [ { diff --git a/packages/core/test/form.test.ts b/packages/core/test/form.test.ts index 5ee579668b..76f0aa0162 100644 --- a/packages/core/test/form.test.ts +++ b/packages/core/test/form.test.ts @@ -15,6 +15,7 @@ const input = { id: formID, sessionID: SessionSchema.ID.make("ses_test"), title: "Test form", + coalesce: "test-form", fields: [{ key: "name", type: "string", required: true }], } satisfies Form.CreateInput @@ -32,6 +33,7 @@ describe("Form", () => { yield* Effect.addFinalizer(() => unsubscribe) const fiber = yield* service.ask(input).pipe(Effect.forkScoped) const form = yield* Deferred.await(created) + expect(form.coalesce).toBe("test-form") yield* service.cancel(form.id) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 1f1ec9cb5d..c0831807a2 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -241,6 +241,7 @@ describe("WebSearchTool registration", () => { { sessionID, title: "Web Search", + coalesce: "msg_tool_test:websearch-consent", metadata: { kind: "websearch.provider" }, fields: [ { @@ -298,6 +299,7 @@ describe("WebSearchTool registration", () => { expect(formRequests[1]).toEqual({ sessionID, title: "Choose a web search provider", + coalesce: "msg_tool_test:websearch-provider", metadata: { kind: "websearch.provider" }, fields: [ { diff --git a/packages/schema/src/form.ts b/packages/schema/src/form.ts index 6c2fdc29e8..d495190971 100644 --- a/packages/schema/src/form.ts +++ b/packages/schema/src/form.ts @@ -124,6 +124,9 @@ const InfoBase = { // on non-session owners anywhere else. sessionID: Schema.String, title: Schema.String, + coalesce: Schema.String.pipe(optional).annotate({ + description: "Client-local key for displaying equivalent pending forms once and broadcasting one response.", + }), metadata: Metadata.pipe(optional), } diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index d44e627ee8..973ccf3aed 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -93,13 +93,13 @@ export function Home() { - + {(_) => { const form = forms()[0] return form ? ( - + ) : null diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index 8ea3c6f8c9..0913290891 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -42,7 +42,7 @@ function requestOptions(form: FormWithLocation) { } } -export function FormPrompt(props: { form: FormWithLocation }) { +export function FormPrompt(props: { form: FormWithLocation; forms?: readonly FormWithLocation[] }) { const client = useClient() const themes = useThemes() const theme = useTheme("elevated") @@ -69,6 +69,11 @@ export function FormPrompt(props: { form: FormWithLocation }) { let textarea: TextareaRenderable | undefined let review: ScrollBoxRenderable | undefined + const forms = createMemo(() => { + if (!props.form.coalesce) return [props.form] + return (props.forms ?? [props.form]).filter((form) => form.coalesce === props.form.coalesce) + }) + const message = createMemo(() => { const value = props.form.metadata?.["message"] return typeof value === "string" ? value : undefined @@ -180,24 +185,30 @@ export function FormPrompt(props: { form: FormWithLocation }) { setStore("error", "") } - function replySingle(field: FormAnswerField, value: FormValue) { - client.api.form - .reply( - { - sessionID: props.form.sessionID, - formID: props.form.id, - answer: { [field.key]: value }, - }, - requestOptions(props.form), + function reply(answer: Record) { + Promise.all( + forms().map((form) => + client.api.form.reply( + { + sessionID: form.sessionID, + formID: form.id, + answer, + }, + requestOptions(form), + ), + ), + ).catch((error: unknown) => { + setStore( + "error", + typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" + ? error.message + : "Invalid answer", ) - .catch((error: unknown) => { - setStore( - "error", - typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" - ? error.message - : "Invalid answer", - ) - }) + }) + } + + function replySingle(field: FormAnswerField, value: FormValue) { + reply({ [field.key]: value }) } function pick(value: FormValue, customValue?: string) { @@ -350,7 +361,8 @@ export function FormPrompt(props: { form: FormWithLocation }) { } function cancel() { - void client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form)) + for (const form of forms()) + void client.api.form.cancel({ sessionID: form.sessionID, formID: form.id }, requestOptions(form)) } function openExternal() { @@ -402,28 +414,14 @@ export function FormPrompt(props: { form: FormWithLocation }) { setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer") return } - client.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", - ) - }) + reply( + Object.fromEntries( + fields().flatMap((field) => { + const value = store.answers[field.key] + return value === undefined ? [] : [[field.key, value] as const] + }), + ), + ) } onMount(() => onCleanup(keymap.mode.push(FORM_MODE))) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index dab3cf6820..4399296753 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1026,10 +1026,10 @@ export function Session() { 0}> - + {(_) => { const form = forms()[0] - return form ? : null + return form ? : null }} diff --git a/packages/tui/test/cli/tui/form.test.tsx b/packages/tui/test/cli/tui/form.test.tsx index 9aa82f7a89..b44158ab76 100644 --- a/packages/tui/test/cli/tui/form.test.tsx +++ b/packages/tui/test/cli/tui/form.test.tsx @@ -15,7 +15,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createApi, createEventStream, createFetch } from "../../fixture/tui-client" -async function mountForm(root: string, width = 80) { +async function mountForm(root: string, width = 80, coalesce = false) { const state = path.join(root, "state") await mkdir(state, { recursive: true }) @@ -24,7 +24,7 @@ async function mountForm(root: string, width = 80) { const events = createEventStream() const transport = createFetch( (url, request) => - url.pathname === "/api/session/ses_test/form/frm_test/reply" + /^\/api\/session\/ses_test\/form\/frm_(?:test|other)\/reply$/.test(url.pathname) ? request.json().then((answer) => { replies.push(answer) return new Response(null, { status: 204 }) @@ -37,6 +37,7 @@ async function mountForm(root: string, width = 80) { id: "frm_test", sessionID: "ses_test", title: "Authorization required", + ...(coalesce ? { coalesce: "authorization" } : {}), fields: [ { key: "authorization", @@ -71,7 +72,7 @@ async function mountForm(root: string, width = 80) { Promise.resolve({}) }}> - + @@ -126,3 +127,24 @@ test("includes external acknowledgements in progress", async () => { prompt.app.renderer.destroy() } }) + +test("replies to every coalesced form", async () => { + await using tmp = await tmpdir() + const prompt = await mountForm(tmp.path, 80, true) + 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")) + prompt.app.mockInput.pressKey("left") + prompt.app.mockInput.pressKey("c") + await prompt.app.waitForFrame((frame) => frame.includes("press enter to confirm")) + prompt.app.mockInput.pressEnter() + await prompt.app.waitForFrame((frame) => frame.includes("Acknowledged")) + prompt.app.mockInput.pressEnter() + await prompt.app.waitFor(() => prompt.replies.length === 2) + expect(prompt.replies).toEqual([{ answer: { authorization: true } }, { answer: { authorization: true } }]) + } finally { + prompt.app.renderer.destroy() + } +})