From 0cd95719b6a8bc9a174067d204909f4905eefe6e Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 23 Mar 2026 14:20:43 -0400
Subject: [PATCH 1/8] Fix dev apps form: union types, textarea support, JSON
parsing
---
src/fastmcp/cli/apps_dev.py | 62 +++++++++++++++++++++++++++++++++----
src/fastmcp/types.py | 32 +++++++++++++++++++
2 files changed, 88 insertions(+), 6 deletions(-)
create mode 100644 src/fastmcp/types.py
diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py
index ac15b9c4c..bb17a235f 100644
--- a/src/fastmcp/cli/apps_dev.py
+++ b/src/fastmcp/cli/apps_dev.py
@@ -871,6 +871,30 @@ def _model_from_schema(tool_name: str, input_schema: dict[str, Any]) -> type[Any
field_definitions: dict[str, Any] = {}
for prop_name, prop in properties.items():
json_type = prop.get("type", "string")
+
+ # Handle anyOf / oneOf (union types like str | dict | None)
+ for key in ("anyOf", "oneOf"):
+ if key in prop:
+ non_null = [
+ t
+ for t in prop[key]
+ if isinstance(t, dict) and t.get("type") != "null"
+ ]
+ if non_null:
+ types = [t.get("type") for t in non_null if "type" in t]
+ for candidate in (
+ "object",
+ "array",
+ "integer",
+ "number",
+ "boolean",
+ "string",
+ ):
+ if candidate in types:
+ json_type = candidate
+ break
+ break
+
match json_type:
case "integer":
py_type: type = int
@@ -878,6 +902,9 @@ def _model_from_schema(tool_name: str, input_schema: dict[str, Any]) -> type[Any
py_type = float
case "boolean":
py_type = bool
+ case "object" | "array":
+ # Render as a string textarea; api_launch parses JSON later
+ py_type = str
case _:
py_type = str
@@ -897,10 +924,20 @@ def _model_from_schema(tool_name: str, input_schema: dict[str, Any]) -> type[Any
from typing import Literal
py_type = Literal[tuple(prop["enum"])] # type: ignore[assignment]
- if prop.get("format") == "textarea" or (
- isinstance(prop.get("json_schema_extra"), dict)
- and prop["json_schema_extra"].get("ui", {}).get("type") == "textarea"
- ):
+
+ # Textarea detection:
+ # 1. Explicit format: "textarea" in JSON schema
+ # 2. UI annotation: {"ui": {"type": "textarea"}} (json_schema_extra merged flat)
+ # 3. Object/array types need multiline JSON editing
+ use_textarea = (
+ prop.get("format") == "textarea"
+ or (
+ isinstance(prop.get("ui"), dict)
+ and prop["ui"].get("type") == "textarea"
+ )
+ or json_type in ("object", "array")
+ )
+ if use_textarea:
extra["json_schema_extra"] = {"ui": {"type": "textarea"}}
field_definitions[prop_name] = (
@@ -1219,8 +1256,21 @@ def _make_dev_app(
"""Picker form submits here; returns a /launch URL string for OpenLink."""
data = await request.json()
tool = data.pop("tool", "")
- # Remaining keys are tool arguments; pass all including empty optionals
- tool_args = dict(data)
+ # Remaining keys are tool arguments. Form inputs always produce
+ # strings, but some parameters expect dicts/lists — try to parse
+ # string values that look like JSON objects or arrays.
+ tool_args: dict[str, Any] = {}
+ for k, v in data.items():
+ if isinstance(v, str):
+ stripped = v.strip()
+ if stripped and stripped[0] in ("{", "["):
+ try:
+ parsed = json.loads(stripped)
+ if isinstance(parsed, (dict, list)):
+ v = parsed
+ except (json.JSONDecodeError, TypeError):
+ pass
+ tool_args[k] = v
args_json = quote(json.dumps(tool_args))
url = f"/launch?tool={tool}&args={args_json}"
return Response(
diff --git a/src/fastmcp/types.py b/src/fastmcp/types.py
new file mode 100644
index 000000000..f078ceb6b
--- /dev/null
+++ b/src/fastmcp/types.py
@@ -0,0 +1,32 @@
+"""Reusable type annotations for FastMCP tool parameters.
+
+These types can be used in tool function signatures to influence how
+parameters are presented in UIs (e.g. ``fastmcp dev apps``) and
+serialized in JSON Schema.
+
+Example::
+
+ from fastmcp import FastMCP
+ from fastmcp.types import Textarea
+
+ mcp = FastMCP("demo")
+
+ @mcp.tool()
+ def run_query(sql: Textarea) -> str:
+ ...
+"""
+
+from __future__ import annotations
+
+from typing import Annotated
+
+from pydantic import Field
+
+Textarea = Annotated[str, Field(json_schema_extra={"format": "textarea"})]
+"""A string rendered as a multiline textarea in form-based UIs.
+
+Produces ``"format": "textarea"`` in the JSON Schema, which
+``fastmcp dev apps`` picks up automatically.
+"""
+
+__all__ = ["Textarea"]
From 854368f1661f849407996fddc129a0c73b1e7219 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 23 Mar 2026 15:14:12 -0400
Subject: [PATCH 2/8] Prefer string over scalars when resolving anyOf unions
---
src/fastmcp/cli/apps_dev.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py
index bb17a235f..9e141a94b 100644
--- a/src/fastmcp/cli/apps_dev.py
+++ b/src/fastmcp/cli/apps_dev.py
@@ -882,13 +882,15 @@ def _model_from_schema(tool_name: str, input_schema: dict[str, Any]) -> type[Any
]
if non_null:
types = [t.get("type") for t in non_null if "type" in t]
+ # Prefer object/array (need textarea for JSON editing),
+ # then string (most versatile text input), then scalars.
for candidate in (
"object",
"array",
+ "string",
"integer",
"number",
"boolean",
- "string",
):
if candidate in types:
json_type = candidate
From 20751418640d02242d76ff8058dda4c68c73e4c9 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 23 Mar 2026 15:24:01 -0400
Subject: [PATCH 3/8] Add JSON mode tab, reset button, render description as
markdown
---
src/fastmcp/cli/apps_dev.py | 107 +++++++++++++++++++++++++-----------
1 file changed, 75 insertions(+), 32 deletions(-)
diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py
index 9e141a94b..23aa1876c 100644
--- a/src/fastmcp/cli/apps_dev.py
+++ b/src/fastmcp/cli/apps_dev.py
@@ -562,6 +562,7 @@ _LOG_PANEL_HTML = """\
\u00b7 0
+
@@ -968,6 +969,9 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str:
Pages,
Select,
SelectOption,
+ Tab,
+ Tabs,
+ Textarea,
)
from prefab_ui.components.form import Form
from prefab_ui.rx import RESULT, Rx
@@ -1013,27 +1017,61 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str:
input_schema: dict[str, Any] = tool.get("inputSchema") or {}
model = _model_from_schema(name, input_schema)
- body: dict[str, Any] = {"tool": name}
+ form_body: dict[str, Any] = {"tool": name}
for field_name in model.model_fields:
- body[field_name] = Rx(field_name)
+ form_body[field_name] = Rx(field_name)
+
+ json_body: dict[str, Any] = {
+ "tool": name,
+ "_json_args": Rx("_json_args"),
+ }
+
+ on_error = ShowToast(Rx("$error"), variant="error") # type: ignore[arg-type]
with Page(name, value=name), Column(gap=4):
if desc:
- Muted(desc, css_class="pb-2")
- with Form(
- on_submit=Fetch.post(
- "/api/launch",
- body=body,
- on_success=OpenLink(RESULT),
- on_error=ShowToast(Rx("$error"), variant="error"), # type: ignore[arg-type]
- ),
- ):
- Form.from_model(model, fields_only=True)
- Button(
- "Launch",
- variant="success",
- button_type="submit",
- )
+ Markdown(desc, css_class="pb-2 text-sm text-muted-foreground")
+ with Tabs(variant="line"):
+ with (
+ Tab("Form"),
+ Form(
+ on_submit=Fetch.post(
+ "/api/launch",
+ body=form_body,
+ on_success=OpenLink(RESULT),
+ on_error=on_error,
+ ),
+ ),
+ ):
+ Form.from_model(model, fields_only=True)
+ Button(
+ "Launch",
+ variant="success",
+ button_type="submit",
+ )
+ with (
+ Tab("JSON"),
+ Form(
+ on_submit=Fetch.post(
+ "/api/launch",
+ body=json_body,
+ on_success=OpenLink(RESULT),
+ on_error=on_error,
+ ),
+ ),
+ ):
+ with Column(gap=2):
+ Label("Arguments")
+ Textarea(
+ name="_json_args",
+ placeholder='{"key": "value"}',
+ rows=8,
+ )
+ Button(
+ "Launch",
+ variant="success",
+ button_type="submit",
+ )
Markdown(
"Generated by [Prefab](https://prefab.prefect.io) 🎨",
@@ -1258,21 +1296,26 @@ def _make_dev_app(
"""Picker form submits here; returns a /launch URL string for OpenLink."""
data = await request.json()
tool = data.pop("tool", "")
- # Remaining keys are tool arguments. Form inputs always produce
- # strings, but some parameters expect dicts/lists — try to parse
- # string values that look like JSON objects or arrays.
- tool_args: dict[str, Any] = {}
- for k, v in data.items():
- if isinstance(v, str):
- stripped = v.strip()
- if stripped and stripped[0] in ("{", "["):
- try:
- parsed = json.loads(stripped)
- if isinstance(parsed, (dict, list)):
- v = parsed
- except (json.JSONDecodeError, TypeError):
- pass
- tool_args[k] = v
+
+ # JSON mode: the entire argument dict arrives as a raw JSON string.
+ raw_json_args = data.pop("_json_args", None)
+ if raw_json_args is not None:
+ tool_args = json.loads(raw_json_args) if raw_json_args.strip() else {}
+ else:
+ # Form mode: inputs are always strings — try to parse values
+ # that look like JSON objects or arrays.
+ tool_args = {}
+ for k, v in data.items():
+ if isinstance(v, str):
+ stripped = v.strip()
+ if stripped and stripped[0] in ("{", "["):
+ try:
+ parsed = json.loads(stripped)
+ if isinstance(parsed, (dict, list)):
+ v = parsed
+ except (json.JSONDecodeError, TypeError):
+ pass
+ tool_args[k] = v
args_json = quote(json.dumps(tool_args))
url = f"/launch?tool={tool}&args={args_json}"
return Response(
From 3b5d2c3a6a3f1f7439f4f84cfa3fa43e5eb54cb2 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 23 Mar 2026 15:26:48 -0400
Subject: [PATCH 4/8] Widen picker, collapse long descriptions into accordion
---
src/fastmcp/cli/apps_dev.py | 16 ++++++++++++++--
1 file changed, 14 insertions(+), 2 deletions(-)
diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py
index 23aa1876c..a6a7d76b8 100644
--- a/src/fastmcp/cli/apps_dev.py
+++ b/src/fastmcp/cli/apps_dev.py
@@ -959,6 +959,8 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str:
from prefab_ui.actions import Fetch, OpenLink, SetState, ShowToast
from prefab_ui.app import PrefabApp
from prefab_ui.components import (
+ Accordion,
+ AccordionItem,
Button,
Column,
Heading,
@@ -991,7 +993,7 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str:
def _tool_title(tool: dict[str, Any]) -> str:
return tool.get("title") or tool["name"]
- with Column(gap=6, css_class="p-8 max-w-lg mx-auto") as view:
+ with Column(gap=6, css_class="p-8 max-w-2xl mx-auto") as view:
Heading("FastMCP Apps")
if len(tools) > 1:
@@ -1030,7 +1032,17 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str:
with Page(name, value=name), Column(gap=4):
if desc:
- Markdown(desc, css_class="pb-2 text-sm text-muted-foreground")
+ # Show the first paragraph inline; collapse the
+ # rest into an expandable accordion.
+ parts = desc.split("\n\n", 1)
+ md_css = "text-sm text-muted-foreground"
+ Muted(parts[0])
+ if len(parts) > 1:
+ with (
+ Accordion(collapsible=True, css_class=md_css),
+ AccordionItem(title="Details"),
+ ):
+ Markdown(parts[1])
with Tabs(variant="line"):
with (
Tab("Form"),
From 1d20fc52213d84d0bb5e9b1a4db58c51d97c7726 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 23 Mar 2026 15:28:17 -0400
Subject: [PATCH 5/8] Truncate long descriptions with show more/less toggle
---
src/fastmcp/cli/apps_dev.py | 36 ++++++++++++++++++++++++------------
1 file changed, 24 insertions(+), 12 deletions(-)
diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py
index a6a7d76b8..b1cfe9c1f 100644
--- a/src/fastmcp/cli/apps_dev.py
+++ b/src/fastmcp/cli/apps_dev.py
@@ -959,8 +959,6 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str:
from prefab_ui.actions import Fetch, OpenLink, SetState, ShowToast
from prefab_ui.app import PrefabApp
from prefab_ui.components import (
- Accordion,
- AccordionItem,
Button,
Column,
Heading,
@@ -1030,19 +1028,33 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str:
on_error = ShowToast(Rx("$error"), variant="error") # type: ignore[arg-type]
+ _desc_max_lines = 10
with Page(name, value=name), Column(gap=4):
if desc:
- # Show the first paragraph inline; collapse the
- # rest into an expandable accordion.
- parts = desc.split("\n\n", 1)
+ lines = desc.split("\n")
md_css = "text-sm text-muted-foreground"
- Muted(parts[0])
- if len(parts) > 1:
- with (
- Accordion(collapsible=True, css_class=md_css),
- AccordionItem(title="Details"),
- ):
- Markdown(parts[1])
+ if len(lines) <= _desc_max_lines:
+ Markdown(desc, css_class=md_css)
+ else:
+ desc_state = f"_desc_{name}"
+ short = "\n".join(lines[:_desc_max_lines])
+ with Pages(name=desc_state, value="short"):
+ with Page("short", value="short"):
+ Markdown(short, css_class=md_css)
+ Button(
+ "Show more",
+ variant="link",
+ size="sm",
+ on_click=SetState(desc_state, "full"),
+ )
+ with Page("full", value="full"):
+ Markdown(desc, css_class=md_css)
+ Button(
+ "Show less",
+ variant="link",
+ size="sm",
+ on_click=SetState(desc_state, "short"),
+ )
with Tabs(variant="line"):
with (
Tab("Form"),
From e363dc2ab08302e9f4030dec199b905cd04ea4ba Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 23 Mar 2026 15:42:35 -0400
Subject: [PATCH 6/8] Picker UI polish: description, JSON toggle, back button
---
src/fastmcp/cli/apps_dev.py | 94 +++++++++++++++++--------------------
1 file changed, 42 insertions(+), 52 deletions(-)
diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py
index b1cfe9c1f..00b141b33 100644
--- a/src/fastmcp/cli/apps_dev.py
+++ b/src/fastmcp/cli/apps_dev.py
@@ -562,7 +562,8 @@ _LOG_PANEL_HTML = """\
\u00b7 0
-
+
+
@@ -969,8 +970,6 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str:
Pages,
Select,
SelectOption,
- Tab,
- Tabs,
Textarea,
)
from prefab_ui.components.form import Form
@@ -1028,74 +1027,65 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str:
on_error = ShowToast(Rx("$error"), variant="error") # type: ignore[arg-type]
- _desc_max_lines = 10
+ input_mode = f"_mode_{name}"
with Page(name, value=name), Column(gap=4):
if desc:
- lines = desc.split("\n")
- md_css = "text-sm text-muted-foreground"
- if len(lines) <= _desc_max_lines:
- Markdown(desc, css_class=md_css)
- else:
- desc_state = f"_desc_{name}"
- short = "\n".join(lines[:_desc_max_lines])
- with Pages(name=desc_state, value="short"):
- with Page("short", value="short"):
- Markdown(short, css_class=md_css)
- Button(
- "Show more",
- variant="link",
- size="sm",
- on_click=SetState(desc_state, "full"),
- )
- with Page("full", value="full"):
- Markdown(desc, css_class=md_css)
- Button(
- "Show less",
- variant="link",
- size="sm",
- on_click=SetState(desc_state, "short"),
- )
- with Tabs(variant="line"):
- with (
- Tab("Form"),
- Form(
+ first_para = desc.split("\n\n", 1)[0]
+ Muted(first_para)
+
+ with Pages(name=input_mode, value="form"):
+ with Page("form", value="form"), Column(gap=4):
+ with Column(gap=1, css_class="items-start"):
+ Heading("Arguments", level=3)
+ Button(
+ "Edit as JSON",
+ variant="link",
+ size="xs",
+ on_click=SetState(input_mode, "json"),
+ css_class="text-muted-foreground p-0 h-auto",
+ )
+ with Form(
on_submit=Fetch.post(
"/api/launch",
body=form_body,
on_success=OpenLink(RESULT),
on_error=on_error,
),
- ),
- ):
- Form.from_model(model, fields_only=True)
- Button(
- "Launch",
- variant="success",
- button_type="submit",
- )
- with (
- Tab("JSON"),
- Form(
+ ):
+ Form.from_model(model, fields_only=True)
+ Button(
+ "Launch",
+ variant="success",
+ button_type="submit",
+ )
+ with Page("json", value="json"), Column(gap=4):
+ with Column(gap=1, css_class="items-start"):
+ Heading("Arguments", level=3)
+ Button(
+ "Use form",
+ variant="link",
+ size="xs",
+ on_click=SetState(input_mode, "form"),
+ css_class="text-muted-foreground p-0 h-auto",
+ )
+ with Form(
on_submit=Fetch.post(
"/api/launch",
body=json_body,
on_success=OpenLink(RESULT),
on_error=on_error,
),
- ),
- ):
- with Column(gap=2):
- Label("Arguments")
+ ):
Textarea(
name="_json_args",
placeholder='{"key": "value"}',
rows=8,
)
- Button(
- "Launch",
- variant="success",
- button_type="submit",
- )
+ Button(
+ "Launch",
+ variant="success",
+ button_type="submit",
+ )
Markdown(
"Generated by [Prefab](https://prefab.prefect.io) 🎨",
From 106b08e6548f27dc178cafed64ae46ca18485a73 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 23 Mar 2026 15:48:09 -0400
Subject: [PATCH 7/8] Rename _json_args to __json_args__, validate JSON input
---
src/fastmcp/cli/apps_dev.py | 30 ++++++++++++++++++++++++++----
1 file changed, 26 insertions(+), 4 deletions(-)
diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py
index 00b141b33..116e2a47d 100644
--- a/src/fastmcp/cli/apps_dev.py
+++ b/src/fastmcp/cli/apps_dev.py
@@ -1022,7 +1022,7 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str:
json_body: dict[str, Any] = {
"tool": name,
- "_json_args": Rx("_json_args"),
+ "__json_args__": Rx("__json_args__"),
}
on_error = ShowToast(Rx("$error"), variant="error") # type: ignore[arg-type]
@@ -1077,7 +1077,7 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str:
),
):
Textarea(
- name="_json_args",
+ name="__json_args__",
placeholder='{"key": "value"}',
rows=8,
)
@@ -1312,9 +1312,31 @@ def _make_dev_app(
tool = data.pop("tool", "")
# JSON mode: the entire argument dict arrives as a raw JSON string.
- raw_json_args = data.pop("_json_args", None)
+ # Key uses a dunder prefix to avoid collisions with real tool params.
+ raw_json_args = data.pop("__json_args__", None)
if raw_json_args is not None:
- tool_args = json.loads(raw_json_args) if raw_json_args.strip() else {}
+ if not raw_json_args.strip():
+ tool_args = {}
+ else:
+ try:
+ tool_args = json.loads(raw_json_args)
+ except json.JSONDecodeError as exc:
+ return Response(
+ content=json.dumps({"error": f"Invalid JSON: {exc}"}),
+ status_code=400,
+ media_type="application/json",
+ )
+ if not isinstance(tool_args, dict):
+ return Response(
+ content=json.dumps(
+ {
+ "error": "JSON must be an object, not "
+ + type(tool_args).__name__
+ }
+ ),
+ status_code=400,
+ media_type="application/json",
+ )
else:
# Form mode: inputs are always strings — try to parse values
# that look like JSON objects or arrays.
From dfefc51d256cd3562f48e38734c000c53848cc4b Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Mon, 23 Mar 2026 16:34:16 -0400
Subject: [PATCH 8/8] Truncate long descriptions with show more/less toggle
---
src/fastmcp/cli/apps_dev.py | 35 +++++++++++++++++++++++++++++++++--
1 file changed, 33 insertions(+), 2 deletions(-)
diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py
index 116e2a47d..a2f16c0eb 100644
--- a/src/fastmcp/cli/apps_dev.py
+++ b/src/fastmcp/cli/apps_dev.py
@@ -1028,10 +1028,41 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str:
on_error = ShowToast(Rx("$error"), variant="error") # type: ignore[arg-type]
input_mode = f"_mode_{name}"
+ _desc_max_lines = 10
with Page(name, value=name), Column(gap=4):
if desc:
- first_para = desc.split("\n\n", 1)[0]
- Muted(first_para)
+ lines = desc.split("\n")
+ md_css = "text-sm text-muted-foreground"
+ if len(lines) <= _desc_max_lines:
+ Markdown(desc, css_class=md_css)
+ else:
+ desc_state = f"_desc_{name}"
+ short = "\n".join(lines[:_desc_max_lines])
+ with Pages(name=desc_state, value="short"):
+ with (
+ Page("short", value="short"),
+ Column(gap=1, css_class="items-start"),
+ ):
+ Markdown(short, css_class=md_css)
+ Button(
+ "Show more \u25be",
+ variant="link",
+ size="xs",
+ on_click=SetState(desc_state, "full"),
+ css_class="text-muted-foreground p-0 h-auto",
+ )
+ with (
+ Page("full", value="full"),
+ Column(gap=1, css_class="items-start"),
+ ):
+ Markdown(desc, css_class=md_css)
+ Button(
+ "Show less \u25b4",
+ variant="link",
+ size="xs",
+ on_click=SetState(desc_state, "short"),
+ css_class="text-muted-foreground p-0 h-auto",
+ )
with Pages(name=input_mode, value="form"):
with Page("form", value="form"), Column(gap=4):