Milo includes a built-in form system for collecting structured input. Define fields declaratively withFieldSpec, then run form()to get a dictionary of responses.
Quick example
from milo import form, FieldSpec, FieldType
result = form(
FieldSpec("name", "Your name"),
FieldSpec("env", "Environment", field_type=FieldType.SELECT,
choices=("dev", "staging", "prod")),
FieldSpec("confirm", "Deploy?", field_type=FieldType.CONFIRM),
)
# result = {"name": "Alice", "env": "prod", "confirm": True}
Field types
The default field type. Supports cursor movement (arrows, Home, End), insert, delete, and backspace.
FieldSpec("name", "Your name")
FieldSpec("email", "Email", placeholder="user@example.com")
Same as text, but input is masked.
FieldSpec("token", "API Token", field_type=FieldType.PASSWORD)
Cycle through choices with Up/Down arrow keys. Renders as a radio-style list with[x] / [ ]indicators.
FieldSpec("region", "Region", field_type=FieldType.SELECT,
choices=("us-east-1", "eu-west-1", "ap-southeast-1"))
Yes/No toggle. Use Y/N keys or Left/Right arrows.
FieldSpec("proceed", "Continue?", field_type=FieldType.CONFIRM,
default=True)
Validation
Pass a validator function toFieldSpec. It receives the field value and returns None(valid) or an error message string.
def validate_email(value):
if "@" not in value:
return "Must be a valid email address"
return None
FieldSpec("email", "Email", validator=validate_email)
Warning
Validators run on every keystroke for text fields. Keep them fast — avoid network calls or file I/O. For async validation, use a saga on form submission instead.
Keyboard navigation
| Key | Action |
|---|---|
| Tab / Shift+Tab | Move between fields |
| Enter | Submit form (or advance to next field) |
| Up / Down | Cycle select choices |
| Left / Right | Toggle confirm fields |
| Home / End | Jump to start/end of text |
| Ctrl+C | Cancel form |
TTY fallback
When stdin is not a TTY (piped input, CI environments),form() falls back to plain input()prompts automatically. No code changes needed.
Tip
This means forms work in CI pipelines — pipe answers via stdin or set environment variables and read them in your reducer as defaults.
Using form_reducer directly
For full control, useform_reducer directly in an App instead of the form()helper:
from milo import App
from milo.form import form_reducer
specs = [FieldSpec("name", "Name"), FieldSpec("age", "Age")]
app = App(template="form.kida", reducer=form_reducer, initial_state={"specs": specs})
final = app.run()
When to use form() vs form_reducer
Useform() for standalone data collection — it creates its own App, runs it, and returns the result dict. Good for scripts and CLI tools.
Useform_reducer when forms are part of a larger app — embed form state in a Flow screen, combine with other reducers, or add custom middleware.