From a38658d161b0434c14320bfe08f1107a40c5fa16 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 12 Nov 2026 00:06:01 -0500 Subject: [PATCH 1/2] TUI: unmount the test/roots form while a pending request overlay is open (#2967) ink-form gives no way to gate its own useInput/useFocus hooks, so a key typed into a sampling or elicitation overlay also moved focus and edited values in the tool, resource, prompt or roots form underneath it. GatedForm runs the form in ink-form's controlled mode with its value held in a component that stays mounted, and unmounts the Form itself while the overlay owns the keyboard, so nothing underneath sees a key and the draft returns intact. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/tui/__tests__/GatedForm.test.tsx | 94 +++++++++++++++++++ clients/tui/src/components/GatedForm.tsx | 75 +++++++++++++++ .../tui/src/components/PromptTestModal.tsx | 9 +- .../tui/src/components/ResourceTestModal.tsx | 9 +- clients/tui/src/components/RootsModal.tsx | 10 +- clients/tui/src/components/ToolTestModal.tsx | 9 +- 6 files changed, 186 insertions(+), 20 deletions(-) create mode 100644 clients/tui/__tests__/GatedForm.test.tsx create mode 100644 clients/tui/src/components/GatedForm.tsx diff --git a/clients/tui/__tests__/GatedForm.test.tsx b/clients/tui/__tests__/GatedForm.test.tsx new file mode 100644 index 0000000000..e5496c137a --- /dev/null +++ b/clients/tui/__tests__/GatedForm.test.tsx @@ -0,0 +1,94 @@ +import React from "react"; +import { describe, it, expect, vi } from "vitest"; +import type { FormStructure } from "ink-form"; +import { render } from "./helpers/renderTui"; +import { GatedForm, initialFormValues } from "../src/components/GatedForm.js"; + +// The real ink-form, not the double the modal suites use: what is under test +// is that its own key handlers and focusables go away while the form is gated, +// which the double (one Enter handler) cannot show (#2967). + +const tick = async () => { + for (let i = 0; i < 8; i++) + await new Promise((resolve) => setTimeout(resolve, 4)); +}; +const ESC = String.fromCharCode(27); +const DOWN = `${ESC}[B`; + +const FORM: FormStructure = { + sections: [ + { + title: "Args", + fields: [ + { type: "string", name: "city", initialValue: "Oslo" }, + { type: "string", name: "note" }, + ], + }, + ], +}; + +describe("initialFormValues", () => { + it("collects each field's initialValue across sections, skipping undefined", () => { + expect( + initialFormValues({ + sections: [ + FORM.sections[0]!, + { + title: "More", + fields: [{ type: "boolean", name: "flag", initialValue: false }], + }, + ], + }), + ).toEqual({ city: "Oslo", flag: false }); + }); +}); + +describe("GatedForm", () => { + it("submits each field's default, as the uncontrolled form did", async () => { + const onSubmit = vi.fn(); + const api = render(); + await tick(); + // Past both fields to the submit button. + for (const key of [DOWN, DOWN, DOWN, "\r"]) { + api.stdin.write(key); + await tick(); + } + expect(onSubmit).toHaveBeenCalledWith({ city: "Oslo" }); + api.unmount(); + }); + + it("ignores every key while inactive and keeps the draft for when it returns", async () => { + const onSubmit = vi.fn(); + const api = render(); + await tick(); + // Edit the first field: focus it, open it, type, commit. + for (const key of [DOWN, "\r", "X", "\r"]) { + api.stdin.write(key); + await tick(); + } + expect(api.lastFrame()).toContain("OsloX"); + + api.rerender(); + await tick(); + expect(api.lastFrame()).toContain("Paused while a server request is open"); + expect(api.lastFrame()).not.toContain("city"); + // Keys meant for an overlay above: open a field, type, commit, submit. + for (const key of [DOWN, "\r", "junk", "\r", DOWN, DOWN, "\r"]) { + api.stdin.write(key); + await tick(); + } + expect(onSubmit).not.toHaveBeenCalled(); + + api.rerender(); + await tick(); + expect(api.lastFrame()).toContain("OsloX"); + expect(api.lastFrame()).not.toContain("junk"); + for (const key of [DOWN, DOWN, DOWN, "\r"]) { + api.stdin.write(key); + await tick(); + } + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ city: "OsloX" }); + api.unmount(); + }); +}); diff --git a/clients/tui/src/components/GatedForm.tsx b/clients/tui/src/components/GatedForm.tsx new file mode 100644 index 0000000000..a118c2ffd2 --- /dev/null +++ b/clients/tui/src/components/GatedForm.tsx @@ -0,0 +1,75 @@ +/** + * An `ink-form` `
` that can be switched off without losing its draft + * (#2967). + * + * The tool, resource, prompt and roots modals stay mounted underneath a + * pending sampling/elicitation request (#2829), and gate their own `useInput` + * while it is open. `ink-form` offers no such gate: every field, the section + * tabs and the arrow-key focus moves are `useInput` / `useFocus` hooks of its + * own with no `isActive` reachable from outside, and Ink's focus manager is + * shared with the overlay's form. So a key typed into the overlay also moved + * focus and edited values in the form underneath it. + * + * Ink cannot mask stdin for a subtree, so "inert" here means "not mounted": + * while `active` is false the `` is not rendered at all, and none of its + * handlers or focusables exist. The draft survives because the form runs in + * `ink-form`'s **controlled** mode with its value held here, in a component + * that stays mounted across the toggle. The value is seeded from each field's + * `initialValue`, which is what the uncontrolled form did on mount, so a + * submit carries the same defaults it always did. + * + * What does not survive is `ink-form`'s own view state — which section tab and + * which field had focus, and an edit still open in a field (not yet committed + * with Enter), which is discarded the way Esc discards it. Committed values, + * which are everything a submit sends, are kept exactly. + */ +import React, { useState } from "react"; +import { Text } from "ink"; +import { Form, type FormProps, type FormStructure } from "ink-form"; + +interface GatedFormProps { + form: FormStructure; + /** False while another overlay owns the keyboard; the form is unmounted. */ + active: boolean; + onSubmit: (value: object) => void; + customManagers?: FormProps["customManagers"]; +} + +/** Each field's declared `initialValue`, keyed by field name across sections. */ +export function initialFormValues( + form: FormStructure, +): Record { + const values: Record = {}; + for (const section of form.sections) { + for (const field of section.fields) { + if (field.initialValue !== undefined) { + values[field.name] = field.initialValue; + } + } + } + return values; +} + +export function GatedForm({ + form, + active, + onSubmit, + customManagers, +}: GatedFormProps) { + // Seeded once: the modals rebuild `form` every render, but its fields (and + // so their defaults) are fixed for the lifetime of the modal. + const [draft, setDraft] = useState(() => initialFormValues(form)); + + if (!active) { + return Paused while a server request is open; + } + return ( + + ); +} diff --git a/clients/tui/src/components/PromptTestModal.tsx b/clients/tui/src/components/PromptTestModal.tsx index 7dc6761697..ea6394ec38 100644 --- a/clients/tui/src/components/PromptTestModal.tsx +++ b/clients/tui/src/components/PromptTestModal.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { Box, Text, useInput, type Key } from "ink"; -import { Form } from "ink-form"; +import { GatedForm } from "./GatedForm.js"; import { InspectorClient } from "@inspector/core/mcp/index.js"; import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { Prompt, GetPromptResult } from "@modelcontextprotocol/client"; @@ -32,6 +32,7 @@ interface PromptTestModalProps { /** * False while a pending sampling/elicitation request (#2829) is open above * this overlay, so a key meant for that request does not also act here. + * Its form is unmounted meanwhile, draft kept (`GatedForm`, #2967). */ inputActive?: boolean; onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void; @@ -134,9 +135,6 @@ export function PromptTestModal({ ); const handleFormSubmit = async (values: Record) => { - // A pending sampling/elicitation request (#2829) is open above this - // modal: ink-form still sees its keys, so Enter must not submit here. - if (!inputActive) return; if (!inspectorClient || !prompt) return; setState("loading"); @@ -230,8 +228,9 @@ export function PromptTestModal({ {prompt.description} )} - handleFormSubmit(values as Record) } diff --git a/clients/tui/src/components/ResourceTestModal.tsx b/clients/tui/src/components/ResourceTestModal.tsx index f17dc45005..8f84d5f53c 100644 --- a/clients/tui/src/components/ResourceTestModal.tsx +++ b/clients/tui/src/components/ResourceTestModal.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { Box, Text, useInput, type Key } from "ink"; -import { Form } from "ink-form"; +import { GatedForm } from "./GatedForm.js"; import { InspectorClient } from "@inspector/core/mcp/index.js"; import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { ReadResourceResult } from "@modelcontextprotocol/client"; @@ -41,6 +41,7 @@ interface ResourceTestModalProps { /** * False while a pending sampling/elicitation request (#2829) is open above * this overlay, so a key meant for that request does not also act here. + * Its form is unmounted meanwhile, draft kept (`GatedForm`, #2967). */ inputActive?: boolean; onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void; @@ -144,9 +145,6 @@ export function ResourceTestModal({ ); const handleFormSubmit = async (values: Record) => { - // A pending sampling/elicitation request (#2829) is open above this - // modal: ink-form still sees its keys, so Enter must not submit here. - if (!inputActive) return; if (!inspectorClient || !template) return; // RFC 6570 keeps a required expression satisfied by any ONE of its names, @@ -283,8 +281,9 @@ export function ResourceTestModal({ {template.description} )} - handleFormSubmit(values as Record) } diff --git a/clients/tui/src/components/RootsModal.tsx b/clients/tui/src/components/RootsModal.tsx index 13da0cd1b2..9b85fc4b05 100644 --- a/clients/tui/src/components/RootsModal.tsx +++ b/clients/tui/src/components/RootsModal.tsx @@ -14,7 +14,8 @@ */ import React, { useRef, useState } from "react"; import { Box, Text, useInput, type Key } from "ink"; -import { Form, type FormStructure } from "ink-form"; +import type { FormStructure } from "ink-form"; +import { GatedForm } from "./GatedForm.js"; import type { Root } from "@modelcontextprotocol/client"; import type { InspectorClient } from "@inspector/core/mcp/index.js"; import { useSelectableList } from "../hooks/useSelectableList.js"; @@ -58,6 +59,7 @@ interface RootsModalProps { /** * False while a pending sampling/elicitation request (#2829) is open above * this overlay, so a key meant for that request does not also act here. + * Its form is unmounted meanwhile, draft kept (`GatedForm`, #2967). */ inputActive?: boolean; } @@ -104,9 +106,6 @@ export function RootsModal({ }; const handleSubmit = (values: Record) => { - // A pending sampling/elicitation request (#2829) is open above this - // modal: ink-form still sees its keys, so Enter must not submit here. - if (!inputActive) return; const root = rootFromForm(values); if ("error" in root) { setError(root.error); @@ -184,8 +183,9 @@ export function RootsModal({ {mode === "add" ? ( - handleSubmit(values as Record) } diff --git a/clients/tui/src/components/ToolTestModal.tsx b/clients/tui/src/components/ToolTestModal.tsx index cf0937ae05..b0b72dac48 100644 --- a/clients/tui/src/components/ToolTestModal.tsx +++ b/clients/tui/src/components/ToolTestModal.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { Box, Text, useInput, type Key } from "ink"; -import { Form } from "ink-form"; +import { GatedForm } from "./GatedForm.js"; import { InspectorClient } from "@inspector/core/mcp/index.js"; import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { Tool, CallToolResult } from "@modelcontextprotocol/client"; @@ -36,6 +36,7 @@ interface ToolTestModalProps { /** * False while a pending sampling/elicitation request (#2829) is open above * this overlay, so a key meant for that request does not also act here. + * Its form is unmounted meanwhile, draft kept (`GatedForm`, #2967). */ inputActive?: boolean; onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void; @@ -267,9 +268,6 @@ export function ToolTestModal({ }; const handleFormSubmit = async (rawValues: Record) => { - // A pending sampling/elicitation request (#2829) is open above this - // modal: ink-form still sees its keys, so Enter must not submit here. - if (!inputActive) return; if (!inspectorClient || !tool) return; // A root union renders every alternative as its own section, under prefixed @@ -409,8 +407,9 @@ export function ToolTestModal({ {state === "form" && ( - void handleFormSubmit(value as Record) From c5dbafd3654e990ab05808203aa16d077cfdfcba Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 12 Nov 2026 00:54:55 -0500 Subject: [PATCH 2/2] Seed GatedForm defaults with Object.fromEntries so a __proto__ field stays an own value (#2967) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- clients/tui/__tests__/GatedForm.test.tsx | 17 +++++++++++++++++ clients/tui/src/components/GatedForm.tsx | 23 +++++++++++++---------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/clients/tui/__tests__/GatedForm.test.tsx b/clients/tui/__tests__/GatedForm.test.tsx index e5496c137a..9cc5fc1311 100644 --- a/clients/tui/__tests__/GatedForm.test.tsx +++ b/clients/tui/__tests__/GatedForm.test.tsx @@ -41,6 +41,23 @@ describe("initialFormValues", () => { }), ).toEqual({ city: "Oslo", flag: false }); }); + + it("keeps a field named __proto__ as an own value", () => { + const values = initialFormValues({ + sections: [ + { + title: "Args", + fields: [{ type: "string", name: "__proto__", initialValue: "x" }], + }, + ], + }); + expect(Object.prototype.hasOwnProperty.call(values, "__proto__")).toBe( + true, + ); + expect(JSON.parse(JSON.stringify(values))).toEqual( + JSON.parse('{"__proto__":"x"}'), + ); + }); }); describe("GatedForm", () => { diff --git a/clients/tui/src/components/GatedForm.tsx b/clients/tui/src/components/GatedForm.tsx index a118c2ffd2..28ba81fa24 100644 --- a/clients/tui/src/components/GatedForm.tsx +++ b/clients/tui/src/components/GatedForm.tsx @@ -35,19 +35,22 @@ interface GatedFormProps { customManagers?: FormProps["customManagers"]; } -/** Each field's declared `initialValue`, keyed by field name across sections. */ +/** + * Each field's declared `initialValue`, keyed by field name across sections. + * Built with `Object.fromEntries` so a field named `__proto__` becomes an own + * property, as ink-form's own computed-key seeding made it, rather than + * hitting the prototype setter. + */ export function initialFormValues( form: FormStructure, ): Record { - const values: Record = {}; - for (const section of form.sections) { - for (const field of section.fields) { - if (field.initialValue !== undefined) { - values[field.name] = field.initialValue; - } - } - } - return values; + return Object.fromEntries( + form.sections.flatMap((section) => + section.fields + .filter((field) => field.initialValue !== undefined) + .map((field) => [field.name, field.initialValue]), + ), + ); } export function GatedForm({