"""form UI component for navi_ui.
A universal, schema-driven form rendered by Navi and filled out by the user.
Validation is performed client-side in real time. On submit the client sends
a JSON payload back over the WebSocket and the form is replaced by a read-only
summary of the entered data.
"""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator
from .base import UIComponent
class FormOption(BaseModel):
"""Single option for select / multiselect fields."""
label: str = Field(..., min_length=1)
value: str = Field(..., min_length=1)
class FormField(BaseModel):
"""Schema for one form field."""
name: str = Field(
...,
min_length=1,
pattern=r"^[a-zA-Z0-9_-]+$",
description="Unique machine identifier for this field. Use snake_case or camelCase; no spaces.",
)
label: str = Field(..., min_length=1, description="Human-readable label shown above the input.")
type: Literal[
"text",
"textarea",
"number",
"email",
"url",
"select",
"multiselect",
"checkbox",
"date",
] = "text"
required: bool = False
placeholder: str | None = None
default: Any | None = None
options: list[FormOption] | None = None
min: float | int | None = None
max: float | int | None = None
min_length: int | None = Field(None, alias="minLength")
max_length: int | None = Field(None, alias="maxLength")
pattern: str | None = Field(None, description="ECMAScript-style regex pattern for text/textarea fields.")
description: str | None = None
@field_validator("options", mode="before")
@classmethod
def _normalize_options(cls, value: Any) -> Any:
if value is None:
return value
if isinstance(value, list):
return value
raise ValueError("options must be a list of {label, value} objects")
@field_validator("options", mode="before")
@classmethod
def _options_for_selects(
cls, value: Any, info
) -> Any:
data = info.data
field_type = data.get("type")
if field_type in ("select", "multiselect"):
if value is None:
raise ValueError("select and multiselect fields require options")
if isinstance(value, list) and not value:
raise ValueError("select and multiselect fields require at least one option")
return value
class FormPayload(BaseModel):
"""Schema for the `form` UI component payload."""
form_id: str = Field(
...,
min_length=1,
description="Stable identifier for this form instance. Used to correlate the submitted values.",
)
title: str | None = Field(None, description="Optional heading shown above the form.")
description: str | None = Field(
None, description="Optional helper text / instructions shown below the title."
)
fields: list[FormField] = Field(
...,
min_length=1,
max_length=20,
description="Fields to render. Keep the form focused; ask for more data in follow-up turns if needed.",
)
submit_label: str | None = Field("Submit", description="Label for the submit button.")
class Form(UIComponent):
"""Render a universal, schema-driven form in the webclient.
Use this when you need structured input from the user (contact details,
preferences, filters, etc.). Describe each field precisely so the client can
validate it in real time. After the user submits, the form becomes read-only
and the JSON values are delivered to the LLM as a hidden user message.
"""
name = "form"
description = (
"A universal schema-driven form for structured user input. "
"Client-side validation only; the LLM receives the submitted JSON values."
)
schema = FormPayload