diff --git a/canvas/src/components/tabs/config/__tests__/form-inputs.test.tsx b/canvas/src/components/tabs/config/__tests__/form-inputs.test.tsx
new file mode 100644
index 00000000..3e55f7f9
--- /dev/null
+++ b/canvas/src/components/tabs/config/__tests__/form-inputs.test.tsx
@@ -0,0 +1,451 @@
+// @vitest-environment jsdom
+/**
+ * form-inputs — pure presentational form primitives for the Config tab.
+ *
+ * NOTE: No @testing-library/jest-dom import — use textContent / className /
+ * getAttribute / checked / value checks to avoid "expect is not defined"
+ * errors in this vitest configuration.
+ *
+ * Covers:
+ * - TextInput renders label and input with correct value
+ * - TextInput calls onChange with new value on keystroke
+ * - TextInput renders placeholder text when provided
+ * - TextInput applies mono class when mono=true
+ * - TextInput input has accessible aria-label from label
+ * - TextInput input is not mono by default
+ * - NumberInput renders label and number input
+ * - NumberInput calls onChange with parsed integer on keystroke
+ * - NumberInput calls onChange with 0 for non-numeric input
+ * - NumberInput respects min/max bounds
+ * - NumberInput input has aria-label from label prop
+ * - NumberInput input has font-mono class
+ * - Toggle renders checkbox with label text
+ * - Toggle renders checked/unchecked state correctly
+ * - Toggle calls onChange with boolean on toggle
+ * - TagList renders existing tags with remove buttons
+ * - TagList × button has aria-label "Remove tag {value}"
+ * - TagList calls onChange without removed tag on × click
+ * - TagList renders the label text
+ * - TagList renders placeholder text when provided
+ * - TagList renders exactly one textbox
+ * - TagList adds tag on Enter key
+ * - TagList does not add empty/whitespace-only tags on Enter
+ * - TagList clears input after adding tag
+ * - Section renders the title
+ * - Section renders children when open (defaultOpen=true)
+ * - Section starts closed when defaultOpen=false
+ * - Section opens/closes content on title click
+ * - Section button has aria-expanded reflecting open state
+ * - Section toggle indicator changes on open/close
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import React from "react";
+
+import {
+ TextInput,
+ NumberInput,
+ Toggle,
+ TagList,
+ Section,
+} from "../form-inputs";
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ vi.resetModules();
+});
+
+// ─── TextInput ───────────────────────────────────────────────────────────────
+
+describe("TextInput", () => {
+ it("renders the label text", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.textContent).toContain("Agent Name");
+ });
+
+ it("renders the input with the given value", () => {
+ render();
+ const input = document.querySelector("input") as HTMLInputElement;
+ expect(input.value).toBe("claude-opus-4");
+ });
+
+ it("calls onChange with new value on keystroke", () => {
+ const onChange = vi.fn();
+ render();
+ const input = document.querySelector("input") as HTMLInputElement;
+ fireEvent.change(input, { target: { value: "hello world" } });
+ expect(onChange).toHaveBeenCalledWith("hello world");
+ });
+
+ it("renders placeholder text when provided", () => {
+ render(
+ ,
+ );
+ const input = document.querySelector("input") as HTMLInputElement;
+ expect(input.getAttribute("placeholder")).toBe("sk-...");
+ });
+
+ it("applies mono class when mono=true", () => {
+ const { container } = render(
+ ,
+ );
+ const input = container.querySelector("input") as HTMLInputElement;
+ expect(input.className).toContain("font-mono");
+ });
+
+ it("input has aria-label matching the label", () => {
+ render();
+ const input = document.querySelector("input") as HTMLInputElement;
+ expect(input.getAttribute("aria-label")).toBe("API Key");
+ });
+
+ it("input is not mono by default", () => {
+ const { container } = render(
+ ,
+ );
+ const input = container.querySelector("input") as HTMLInputElement;
+ expect(input.className).not.toContain("font-mono");
+ });
+});
+
+// ─── NumberInput ─────────────────────────────────────────────────────────────
+
+describe("NumberInput", () => {
+ it("renders the label text", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.textContent).toContain("Timeout (s)");
+ });
+
+ it("renders the input with the given numeric value", () => {
+ render();
+ const input = document.querySelector("input[type=number]") as HTMLInputElement;
+ expect(input.value).toBe("3");
+ });
+
+ it("calls onChange with parsed integer on keystroke", () => {
+ const onChange = vi.fn();
+ render();
+ const input = document.querySelector("input[type=number]") as HTMLInputElement;
+ fireEvent.change(input, { target: { value: "7" } });
+ expect(onChange).toHaveBeenCalledWith(7);
+ });
+
+ it("calls onChange with 0 for non-numeric input", () => {
+ const onChange = vi.fn();
+ render();
+ const input = document.querySelector("input[type=number]") as HTMLInputElement;
+ fireEvent.change(input, { target: { value: "abc" } });
+ expect(onChange).toHaveBeenCalledWith(0);
+ });
+
+ it("respects min attribute", () => {
+ render(
+ ,
+ );
+ const input = document.querySelector("input[type=number]") as HTMLInputElement;
+ expect(input.getAttribute("min")).toBe("1024");
+ });
+
+ it("respects max attribute", () => {
+ render(
+ ,
+ );
+ const input = document.querySelector("input[type=number]") as HTMLInputElement;
+ expect(input.getAttribute("max")).toBe("65535");
+ });
+
+ it("input has aria-label from label prop", () => {
+ render();
+ const input = document.querySelector("input[type=number]") as HTMLInputElement;
+ expect(input.getAttribute("aria-label")).toBe("Timeout");
+ });
+
+ it("input has font-mono class", () => {
+ const { container } = render(
+ ,
+ );
+ const input = container.querySelector("input") as HTMLInputElement;
+ expect(input.className).toContain("font-mono");
+ });
+});
+
+// ─── Toggle ──────────────────────────────────────────────────────────────────
+
+describe("Toggle", () => {
+ it("renders the checkbox with label text", () => {
+ const { container } = render(
+ ,
+ );
+ const checkbox = container.querySelector(
+ "input[type=checkbox]",
+ ) as HTMLInputElement;
+ expect(checkbox.checked).toBe(false);
+ expect(
+ checkbox.closest("label")?.textContent,
+ ).toContain("Enable streaming");
+ });
+
+ it("renders checked state correctly", () => {
+ const { container } = render(
+ ,
+ );
+ const checkbox = container.querySelector(
+ "input[type=checkbox]",
+ ) as HTMLInputElement;
+ expect(checkbox.checked).toBe(true);
+ });
+
+ it("calls onChange with true when toggled on", () => {
+ const onChange = vi.fn();
+ const { container } = render(
+ ,
+ );
+ const checkbox = container.querySelector(
+ "input[type=checkbox]",
+ ) as HTMLInputElement;
+ checkbox.click();
+ expect(onChange).toHaveBeenCalledWith(true);
+ });
+
+ it("calls onChange with false when toggled off", () => {
+ const onChange = vi.fn();
+ const { container } = render(
+ ,
+ );
+ const checkbox = container.querySelector(
+ "input[type=checkbox]",
+ ) as HTMLInputElement;
+ checkbox.click();
+ expect(onChange).toHaveBeenCalledWith(false);
+ });
+
+ it("checkbox is a native input element", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector("input[type=checkbox]")).toBeTruthy();
+ });
+});
+
+// ─── TagList ────────────────────────────────────────────────────────────────
+
+describe("TagList", () => {
+ it("renders existing tags", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.textContent).toContain("file_read");
+ expect(container.textContent).toContain("bash");
+ });
+
+ it("renders × remove button for each tag with aria-label", () => {
+ render(
+ ,
+ );
+ const buttons = document.querySelectorAll("button");
+ // buttons[0] = first × (python), buttons[1] = second × (golang)
+ expect(buttons[0].getAttribute("aria-label")).toBe(
+ "Remove tag python",
+ );
+ expect(buttons[1].getAttribute("aria-label")).toBe(
+ "Remove tag golang",
+ );
+ });
+
+ it("calls onChange without removed tag when × is clicked", () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ const buttons = document.querySelectorAll("button");
+ // buttons[0] = react ×, buttons[1] = vue ×, buttons[2] = angular ×
+ buttons[0].click(); // Remove react
+ expect(onChange).toHaveBeenCalledWith(["vue", "angular"]);
+ });
+
+ it("renders the label text", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.textContent).toContain("Required env vars");
+ });
+
+ it("renders placeholder text when provided", () => {
+ render(
+ ,
+ );
+ const input = document.querySelector("input[type=text]") as HTMLInputElement;
+ expect(input.getAttribute("placeholder")).toBe("Add a tag...");
+ });
+
+ it("renders exactly one textbox (the input)", () => {
+ const { container } = render(
+ ,
+ );
+ expect(
+ container.querySelectorAll("input[type=text]"),
+ ).toHaveLength(1);
+ });
+
+ it("adds tag on Enter key", () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ const input = document.querySelector("input[type=text]") as HTMLInputElement;
+ fireEvent.change(input, { target: { value: "rust" } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(onChange).toHaveBeenCalledWith(["python", "rust"]);
+ });
+
+ it("does not add empty tag on Enter", () => {
+ const onChange = vi.fn();
+ render(
+ ,
+ );
+ const input = document.querySelector("input[type=text]") as HTMLInputElement;
+ fireEvent.change(input, { target: { value: " " } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it("clears input after adding tag", () => {
+ render(
+ ,
+ );
+ const input = document.querySelector("input[type=text]") as HTMLInputElement;
+ fireEvent.change(input, { target: { value: "golang" } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(input.value).toBe("");
+ });
+});
+
+// ─── Section ───────────────────────────────────────────────────────────────
+
+describe("Section", () => {
+ it("renders the title", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.textContent).toContain("Runtime config");
+ });
+
+ it("renders children when open (defaultOpen=true)", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.textContent).toContain("Hidden content");
+ });
+
+ it("starts closed when defaultOpen=false", () => {
+ const { container } = render(
+
+ Should not be visible
+ ,
+ );
+ expect(container.textContent).not.toContain("Should not be visible");
+ });
+
+ it("opens/closes content on title click", () => {
+ const { container } = render(
+ ,
+ );
+ // Should be closed initially
+ expect(container.textContent).not.toContain("Now you see me");
+ // Click to open
+ const btn = container.querySelector("button") as HTMLButtonElement;
+ fireEvent.click(btn);
+ expect(container.textContent).toContain("Now you see me");
+ // Click to close
+ fireEvent.click(btn);
+ expect(container.textContent).not.toContain("Now you see me");
+ });
+
+ it("title button has aria-expanded reflecting open state", () => {
+ // Open section
+ const { container: openContainer } = render(
+ ,
+ );
+ const openBtn = openContainer.querySelector(
+ "button",
+ ) as HTMLButtonElement;
+ expect(openBtn.getAttribute("aria-expanded")).toBe("true");
+
+ // Closed section
+ const { container: closedContainer } = render(
+ ,
+ );
+ const closedBtn = closedContainer.querySelector(
+ "button",
+ ) as HTMLButtonElement;
+ expect(closedBtn.getAttribute("aria-expanded")).toBe("false");
+ });
+
+ it("toggle indicator changes between ▾ (open) and ▸ (closed)", () => {
+ // Open: uses ▾
+ const { container: openContainer } = render(
+ ,
+ );
+ // Button has two spans: title (first) and indicator (second, aria-hidden)
+ const openSpans = openContainer
+ .querySelectorAll("button span");
+ const openIndicator = openSpans[1]?.textContent?.trim();
+ expect(openIndicator).toBe("▾");
+
+ // Closed: uses ▸
+ const { container: closedContainer } = render(
+ ,
+ );
+ const closedSpans = closedContainer
+ .querySelectorAll("button span");
+ const closedIndicator = closedSpans[1]?.textContent?.trim();
+ expect(closedIndicator).toBe("▸");
+ });
+});
diff --git a/canvas/src/components/tabs/config/form-inputs.tsx b/canvas/src/components/tabs/config/form-inputs.tsx
index 0cf30e7c..1c2725ef 100644
--- a/canvas/src/components/tabs/config/form-inputs.tsx
+++ b/canvas/src/components/tabs/config/form-inputs.tsx
@@ -127,13 +127,21 @@ export function TagList({ label, values, onChange, placeholder }: { label: strin
export function Section({ title, children, defaultOpen = true }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
const [open, setOpen] = useState(defaultOpen);
+ // Stable id for aria-controls linkage
+ const id = `section-content-${title.toLowerCase().replace(/\s+/g, "-")}`;
return (
-
);
}