Interactive Forms

Text, select, confirm, and password fields with validation and TTY fallback.

2 min read 446 words

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()