Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions clients/tui/__tests__/GatedForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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 });
});

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", () => {
it("submits each field's default, as the uncontrolled form did", async () => {
const onSubmit = vi.fn();
const api = render(<GatedForm form={FORM} active onSubmit={onSubmit} />);
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(<GatedForm form={FORM} active onSubmit={onSubmit} />);
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(<GatedForm form={FORM} active={false} onSubmit={onSubmit} />);
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(<GatedForm form={FORM} active onSubmit={onSubmit} />);
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();
});
});
78 changes: 78 additions & 0 deletions clients/tui/src/components/GatedForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* An `ink-form` `<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 `<Form>` 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.
* 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<string, unknown> {
return Object.fromEntries(
form.sections.flatMap((section) =>
section.fields
.filter((field) => field.initialValue !== undefined)
.map((field) => [field.name, field.initialValue]),
),
);
}

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<object>(() => initialFormValues(form));

if (!active) {
return <Text dimColor>Paused while a server request is open</Text>;
}
return (
<Form
form={form}
customManagers={customManagers}
value={draft}
onChange={setDraft}
onSubmit={onSubmit}
/>
);
}
9 changes: 4 additions & 5 deletions clients/tui/src/components/PromptTestModal.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -134,9 +135,6 @@ export function PromptTestModal({
);

const handleFormSubmit = async (values: Record<string, string>) => {
// 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");
Expand Down Expand Up @@ -230,8 +228,9 @@ export function PromptTestModal({
<Text dimColor>{prompt.description}</Text>
</Box>
)}
<Form
<GatedForm
form={untitledForm(formStructure)}
active={inputActive}
onSubmit={(values: object) =>
handleFormSubmit(values as Record<string, string>)
}
Expand Down
9 changes: 4 additions & 5 deletions clients/tui/src/components/ResourceTestModal.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -144,9 +145,6 @@ export function ResourceTestModal({
);

const handleFormSubmit = async (values: Record<string, string>) => {
// 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,
Expand Down Expand Up @@ -283,8 +281,9 @@ export function ResourceTestModal({
<Text dimColor>{template.description}</Text>
</Box>
)}
<Form
<GatedForm
form={untitledForm(formStructure)}
active={inputActive}
onSubmit={(values: object) =>
handleFormSubmit(values as Record<string, string>)
}
Expand Down
10 changes: 5 additions & 5 deletions clients/tui/src/components/RootsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -104,9 +106,6 @@ export function RootsModal({
};

const handleSubmit = (values: Record<string, unknown>) => {
// 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);
Expand Down Expand Up @@ -184,8 +183,9 @@ export function RootsModal({

{mode === "add" ? (
<Box flexGrow={1} flexDirection="column">
<Form
<GatedForm
form={ADD_ROOT_FORM}
active={inputActive}
onSubmit={(values: object) =>
handleSubmit(values as Record<string, unknown>)
}
Expand Down
9 changes: 4 additions & 5 deletions clients/tui/src/components/ToolTestModal.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -267,9 +268,6 @@ export function ToolTestModal({
};

const handleFormSubmit = async (rawValues: Record<string, JsonValue>) => {
// 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
Expand Down Expand Up @@ -409,8 +407,9 @@ export function ToolTestModal({
<Box flexGrow={1} flexDirection="column" overflow="hidden">
{state === "form" && (
<Box flexGrow={1} width="100%">
<Form
<GatedForm
form={untitledForm(formStructure)}
active={inputActive}
customManagers={FORM_FIELD_MANAGERS}
onSubmit={(value: object) =>
void handleFormSubmit(value as Record<string, JsonValue>)
Expand Down
Loading