Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ jobs:
- name: Ruff format check
run: uv run ruff format src/ tests/ benchmarks/ --check

- name: Template compile check
run: uv run python scripts/check_templates.py

typecheck:
name: Type Check
runs-on: ubuntu-latest
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ Things that look reasonable and are wrong here:
- **Top-level imports in `milo/__init__.py`.** Every downstream CLI pays the cost on every invocation. Use `__getattr__`.
- **Refactoring during a bug fix.** Separate PR. Exception: the refactor *is* the fix, or it's a rename-across-files cleanup.
- **`print()` in library code.** T20 is enabled. Use the context's output path or raise.
- **Undeclared template vars, globals, or filters.** Kida 0.7 runs `strict_undefined=True` and milo's `get_env()` defaults `validate_calls=True`. `{{ bar(...) }}` or `{{ x | oops }}` that "used to work" will now raise. Put defaults on your state dataclass, use `| default(...)` / `.get()` at the boundary, and never reference a name that isn't in `env.globals` or passed to `render()`.
- **`{% def %}` nested inside `{% if %}` / `{% for %}`.** Kida requires top-level defs — the renderer only sees definitions declared at the outermost scope. Declare the macro first, then call it conditionally.

---

Expand All @@ -92,6 +94,7 @@ Things that look reasonable and are wrong here:
A change is done when all of these hold:

- [ ] `make lint`, `make ty`, `make test-cov` clean. No new `type: ignore`, no new S110 suppressions without a `# silent:` justification and per-file entry.
- [ ] `uv run python scripts/check_templates.py` clean — every `.kida` file under `src/milo/templates/` and `examples/*/templates/` compiles under strict-undefined + `validate_calls=True`. This runs in CI's lint job; run it locally when you touch a template.
- [ ] Coverage floor (80%, branch-aware) still holds.
- [ ] Tests exercise the *interesting* path: both modes of a flag, MCP dispatch *and* CLI dispatch *and* `call()` for command changes, the failure path for saga effects, malformed input for schema inference.
- [ ] Hot-path changes (`state.py` dispatch, `commands.py` resolution, `schema.py` inference) include a benchmark in the PR. "Didn't benchmark" is OK only if you say why.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ Pick the example closest to your use case, copy its `app.py`, and adapt. Every d
| Sagas for async side effects | [examples/fetcher](examples/fetcher) | `Call`, `Put`, `Select`, `Retry` |
| Parallel concurrent work | [examples/downloader](examples/downloader) | `Fork`, `Call`, `Delay`, `Timeout` |
| Bubbletea-style Cmd thunks | [examples/spinner](examples/spinner) | `Cmd`, `Batch`, `TickCmd`, `ViewState` |
| Live rendering outside an App | [examples/liverender](examples/liverender) | `milo.live.LiveRenderer`, `Spinner`, `terminal_env` |

> Don't see your use case? Run `milo new <name>` to scaffold a fresh CLI with tests, then `milo verify app.py` to confirm it works.

Expand Down
1 change: 1 addition & 0 deletions changelog.d/+kida-0.7-adoption.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Adopt Kida 0.7 capabilities: `inline_components=True` and `validate_calls=True` defaults in `get_env()`; `enable_capture` opt-in kwarg on `get_env()` for static-site / capture flows; new `milo components` subcommand listing bundled and user-defined template defs (with `--json` for tooling, `--path` to scan extra dirs); `milo.live` re-exports for `LiveRenderer`, `Spinner`, `stream_to_terminal`, `terminal_env`; `kida.get_optimal_workers` now sizes the gateway, registry, and saga executor pools by workload type (IO_BOUND for I/O fan-out, RENDER for saga effects); `{% flush %}` boundaries added to `pipeline_progress` and `pipeline_detail` defs to encode streaming contract; CI gains a template compile-check via `scripts/check_templates.py`; new `examples/liverender` shows `LiveRenderer` outside the App harness; new docs page `usage/live.md`.
1 change: 1 addition & 0 deletions changelog.d/+strict-undefined.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Bumped to `kida-templates>=0.7.0,<0.8.0`. Kida 0.7 makes `strict_undefined=True` the default — milo's bundled templates already conformed, so no behaviour changes for callers using stock templates. User templates that relied on silent-undefined fallbacks now raise `UndefinedError` at render; opt back into the loose mode by passing `get_env(strict_undefined=False)`.
2 changes: 1 addition & 1 deletion examples/filepicker/templates/filepicker.kida
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{% from "components/_defs.kida" import header, status_line, key_hints, scrollable_list %}
{% def render_entry(entry, is_selected) %}{% if is_selected %}{% if entry.is_dir %}{{ (entry.name ~ "/") | bold | cyan }}{% else %}{{ entry.name }}{% endif %}{% else %}{% if entry.is_dir %}{{ (entry.name ~ "/") | cyan }}{% else %}{{ entry.name | dim }}{% endif %}{% endif %}{% if not entry.is_dir and entry.size > 0 %} {{ format_size(entry.size) | dim }}{% endif %}{% enddef %}
{{ header("File Picker") }}
{{ hr() }}

Expand All @@ -11,7 +12,6 @@
{% elif state.entries | length == 0 %}
{{ "(empty directory)" | dim }}
{% else %}
{% def render_entry(entry, is_selected) %}{% if is_selected %}{% if entry.is_dir %}{{ (entry.name ~ "/") | bold | cyan }}{% else %}{{ entry.name }}{% endif %}{% else %}{% if entry.is_dir %}{{ (entry.name ~ "/") | cyan }}{% else %}{{ entry.name | dim }}{% endif %}{% endif %}{% if not entry.is_dir and entry.size > 0 %} {{ format_size(entry.size) | dim }}{% endif %}{% enddef %}
{{ scrollable_list(state.entries, state.cursor, render_entry, height=viewport_height, scroll_offset=state.scroll_offset) }}
{% endif %}

Expand Down
45 changes: 45 additions & 0 deletions examples/liverender/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""LiveRenderer outside the App harness — a one-shot progress view.

Use :mod:`milo.live` when you have a straight-line script or subroutine that
wants in-place terminal updates without the ceremony of a reducer-driven
:class:`milo.App`. For keyboard input, message filters, or state that persists
beyond the task, use :class:`milo.App` + :class:`milo.TickCmd` instead.

uv run python examples/liverender/app.py
"""

from __future__ import annotations

import time

from milo.live import LiveRenderer, terminal_env

TEMPLATE = """\
{{- spinner() }} {{ label }}
progress {{ (progress * 100)|round|int }}%
"""

STEPS = (
("Resolving dependencies", 0.15),
("Fetching packages", 0.45),
("Compiling", 0.80),
("Finalizing", 1.00),
)


def main() -> None:
env = terminal_env()
tpl = env.from_string(TEMPLATE, name="liverender")

with LiveRenderer(tpl, refresh_rate=0.08) as live:
live.start_auto(label="Starting", progress=0.0)
for label, progress in STEPS:
live.update(label=label, progress=progress)
time.sleep(0.6)
live.stop_auto()

print("done.")


if __name__ == "__main__":
main()
3 changes: 2 additions & 1 deletion examples/spinner/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@
TickCmd,
ViewState,
)
from milo.live import Spinner

SPINNER = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
SPINNER = Spinner.BRAILLE

URLS = (
"https://example.com",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ description = "Template-driven CLI applications for free-threaded Python"
readme = "README.md"
requires-python = ">=3.14"
license = "MIT"
dependencies = ["kida-templates>=0.6.0"]
dependencies = ["kida-templates>=0.7.0,<0.8.0"]
keywords = ["cli", "terminal", "forms", "free-threading", "template", "elm"]
classifiers = [
"Development Status :: 3 - Alpha",
Expand Down
66 changes: 66 additions & 0 deletions scripts/check_templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Compile every .kida template under milo's built-ins and examples.

Uses ``milo.templates.get_env()`` so validation runs in terminal autoescape
mode with ``inline_components=True`` and ``validate_calls=True`` — the same
configuration that ships at runtime. This catches unknown filters, unknown
globals, arity mismatches, and syntax errors that the upstream
``kida check`` CLI misses because it only knows HTML-autoescape filters.

Exit code 0 = clean, 1 = one or more templates failed to compile.
"""

from __future__ import annotations

import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
BUILTIN = ROOT / "src" / "milo" / "templates"
EXAMPLES = ROOT / "examples"


def _iter_templates(root: Path) -> list[Path]:
return sorted(p for p in root.rglob("*.kida") if "__pycache__" not in p.parts)


def _check_root(root: Path, label: str) -> list[str]:
from kida import FileSystemLoader

from milo.templates import get_env

env = get_env(loader=FileSystemLoader(str(root)))
errors: list[str] = []
for path in _iter_templates(root):
rel = path.relative_to(root).as_posix()
try:
env.get_template(rel)
except Exception as exc:
formatter = getattr(exc, "format_compact", None)
detail = formatter() if callable(formatter) else f"{type(exc).__name__}: {exc}"
errors.append(f"[{label}] {rel}\n{detail}")
return errors


def main() -> int:
all_errors: list[str] = []
all_errors.extend(_check_root(BUILTIN, "builtin"))

if EXAMPLES.exists():
for example in sorted(EXAMPLES.iterdir()):
tmpl_dir = example / "templates"
if tmpl_dir.is_dir():
all_errors.extend(_check_root(tmpl_dir, f"examples/{example.name}"))

if all_errors:
for err in all_errors:
sys.stderr.write(err + "\n\n")
sys.stderr.write(f"FAIL: {len(all_errors)} template(s) failed to compile\n")
return 1

sys.stdout.write("OK: all templates compile cleanly\n")
return 0


if __name__ == "__main__":
sys.exit(main())
106 changes: 106 additions & 0 deletions site/content/docs/usage/live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
---
title: Live Rendering
nav_title: Live
description: In-place terminal updates via milo.live for scripts and one-shot commands.
weight: 61
draft: false
lang: en
tags: [live, rendering, spinner, streaming, kida]
keywords: [live, spinner, liverenderer, stream_to_terminal, milo.live]
category: usage
icon: activity
---

`milo.live` re-exports Kida's terminal live-rendering primitives for use
outside a full [`App`](state.md) event loop. Reach for it when you have a
straight-line script, a one-shot CLI command, or a background subroutine
that wants in-place updates without a reducer.

```python
from milo.live import LiveRenderer, Spinner, stream_to_terminal, terminal_env
```

## When to use which

| Situation | Use |
| ----------------------------------------------------- | ---------------------------- |
| Keyboard input, persistent state, multi-screen flow | `App` + `TickCmd` |
| One-shot progress for a script or command | `LiveRenderer` (context mgr) |
| Emit a template in chunks as it renders | `stream_to_terminal` |
| Just a spinner frame tuple | `Spinner.BRAILLE` / `.DOTS` |

The App harness owns the render loop, message filter, view state, and cursor
lifecycle. `milo.live` hands you raw primitives — simpler, but you write the
loop.

## `LiveRenderer`

Context manager that overwrites its previous output on each `update()`.
Falls back to log-style appends when stdout is not a TTY.

```python
from milo.live import LiveRenderer, terminal_env

env = terminal_env()
tpl = env.from_string("{{ spinner() }} {{ label }}", name="live")

with LiveRenderer(tpl, refresh_rate=0.08) as live:
live.start_auto(label="Working")
do_slow_thing()
live.update(label="Finalizing")
finalize()
```

`LiveRenderer` auto-injects a `spinner` context variable — call it in the
template (`{{ spinner() }}`) to emit and advance a frame on each render.

`start_auto()` / `stop_auto()` run a background refresh thread so animations
keep ticking between explicit `update()` calls.

See `examples/liverender/app.py` for a runnable version.

## `Spinner`

Animated spinner with four built-in frame sets:

- `Spinner.BRAILLE` — ten-frame Braille dots (also aliased `DOTS`)
- `Spinner.LINE` — four-frame ASCII (`- \ | /`)
- `Spinner.ARROW` — eight-frame directional arrow

Use the class attributes when you just need the frame tuple (for example,
feeding `TickCmd` animation inside an `App`):

```python
# examples/spinner/app.py
from milo.live import Spinner

SPINNER = Spinner.BRAILLE # ('⠋', '⠙', '⠹', ...)
```

Instantiate `Spinner(frames)` only when you want a stateful, advancing
spinner outside a `LiveRenderer` (which already provides one).

## `stream_to_terminal`

Render a template in chunks separated by `{% flush %}` boundaries, with a
configurable delay between chunks:

```python
from milo.live import stream_to_terminal, terminal_env

env = terminal_env()
tpl = env.from_string(
"Starting...\n{% flush %}Step 1 done.\n{% flush %}Step 2 done.\n",
name="stream",
)
stream_to_terminal(tpl, delay=0.3)
```

Milo's built-in pipeline defs already place `{% flush %}` boundaries between
phases, so passing a pipeline template here will stream one phase at a time.

## `terminal_env`

Returns a pre-configured Kida `Environment` with terminal autoescape — the
same autoescape milo uses internally. Prefer this over `milo.templates.get_env`
for one-off live rendering that doesn't need milo's component loader chain.
25 changes: 25 additions & 0 deletions src/milo/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ def _cmd_new(args: argparse.Namespace) -> None:
)


def _cmd_components(args: argparse.Namespace) -> None:
"""List bundled and user-defined template components."""
from milo.components_cli import run

paths = tuple(Path(p) for p in args.path or ())
sys.exit(run(paths=paths, as_json=args.json))


def _cmd_replay(args: argparse.Namespace) -> None:
"""Replay a recorded session."""
from milo.testing._record import load_recording
Expand Down Expand Up @@ -171,6 +179,21 @@ def main(argv: list[str] | None = None) -> None:
"--poll", type=float, default=0.5, help="Poll interval in seconds (default: 0.5)"
)

# milo components
components_parser = subparsers.add_parser(
"components", help="List bundled + user template components (defs)"
)
components_parser.add_argument(
"--path",
"-p",
action="append",
default=[],
help="Extra templates dir to scan (repeatable)",
)
components_parser.add_argument(
"--json", action="store_true", help="Emit full def metadata as JSON"
)

# milo replay
replay_parser = subparsers.add_parser("replay", help="Replay a recorded session")
replay_parser.add_argument("session", help="Path to session JSONL file")
Expand Down Expand Up @@ -199,6 +222,8 @@ def main(argv: list[str] | None = None) -> None:
_cmd_verify(args)
elif args.command == "dev":
_cmd_dev(args)
elif args.command == "components":
_cmd_components(args)
elif args.command == "replay":
_cmd_replay(args)
else:
Expand Down
Loading
Loading