milo-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- milo/__init__.py +183 -0
- milo/_child.py +141 -0
- milo/_errors.py +189 -0
- milo/_protocols.py +37 -0
- milo/_types.py +234 -0
- milo/app.py +353 -0
- milo/cli.py +134 -0
- milo/commands.py +951 -0
- milo/config.py +250 -0
- milo/context.py +81 -0
- milo/dev.py +238 -0
- milo/flow.py +146 -0
- milo/form.py +277 -0
- milo/gateway.py +393 -0
- milo/groups.py +194 -0
- milo/help.py +84 -0
- milo/input/__init__.py +6 -0
- milo/input/_platform.py +81 -0
- milo/input/_reader.py +93 -0
- milo/input/_sequences.py +63 -0
- milo/llms.py +172 -0
- milo/mcp.py +299 -0
- milo/middleware.py +67 -0
- milo/observability.py +111 -0
- milo/output.py +106 -0
- milo/pipeline.py +276 -0
- milo/plugins.py +168 -0
- milo/py.typed +0 -0
- milo/registry.py +213 -0
- milo/schema.py +214 -0
- milo/state.py +229 -0
- milo/streaming.py +41 -0
- milo/templates/__init__.py +38 -0
- milo/templates/error.kida +5 -0
- milo/templates/field_confirm.kida +1 -0
- milo/templates/field_select.kida +3 -0
- milo/templates/field_text.kida +1 -0
- milo/templates/form.kida +8 -0
- milo/templates/help.kida +7 -0
- milo/templates/progress.kida +1 -0
- milo/testing/__init__.py +27 -0
- milo/testing/_mcp.py +87 -0
- milo/testing/_record.py +125 -0
- milo/testing/_replay.py +68 -0
- milo/testing/_snapshot.py +96 -0
- milo_cli-0.1.0.dist-info/METADATA +441 -0
- milo_cli-0.1.0.dist-info/RECORD +50 -0
- milo_cli-0.1.0.dist-info/WHEEL +5 -0
- milo_cli-0.1.0.dist-info/entry_points.txt +2 -0
- milo_cli-0.1.0.dist-info/top_level.txt +1 -0
milo/_types.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Frozen dataclasses, enums, and type aliases — no internal imports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Generator
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum, auto
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
# Keys
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SpecialKey(Enum):
|
|
16
|
+
ENTER = auto()
|
|
17
|
+
TAB = auto()
|
|
18
|
+
BACKSPACE = auto()
|
|
19
|
+
DELETE = auto()
|
|
20
|
+
ESCAPE = auto()
|
|
21
|
+
UP = auto()
|
|
22
|
+
DOWN = auto()
|
|
23
|
+
LEFT = auto()
|
|
24
|
+
RIGHT = auto()
|
|
25
|
+
HOME = auto()
|
|
26
|
+
END = auto()
|
|
27
|
+
PAGE_UP = auto()
|
|
28
|
+
PAGE_DOWN = auto()
|
|
29
|
+
INSERT = auto()
|
|
30
|
+
F1 = auto()
|
|
31
|
+
F2 = auto()
|
|
32
|
+
F3 = auto()
|
|
33
|
+
F4 = auto()
|
|
34
|
+
F5 = auto()
|
|
35
|
+
F6 = auto()
|
|
36
|
+
F7 = auto()
|
|
37
|
+
F8 = auto()
|
|
38
|
+
F9 = auto()
|
|
39
|
+
F10 = auto()
|
|
40
|
+
F11 = auto()
|
|
41
|
+
F12 = auto()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class Key:
|
|
46
|
+
"""Single keypress."""
|
|
47
|
+
|
|
48
|
+
char: str = ""
|
|
49
|
+
name: SpecialKey | None = None
|
|
50
|
+
ctrl: bool = False
|
|
51
|
+
alt: bool = False
|
|
52
|
+
shift: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Actions
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class Action:
|
|
62
|
+
"""Event dispatched to a reducer."""
|
|
63
|
+
|
|
64
|
+
type: str
|
|
65
|
+
payload: Any = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
BUILTIN_ACTIONS: frozenset[str] = frozenset(
|
|
69
|
+
{
|
|
70
|
+
"@@INIT",
|
|
71
|
+
"@@KEY",
|
|
72
|
+
"@@TICK",
|
|
73
|
+
"@@RESIZE",
|
|
74
|
+
"@@EFFECT_RESULT",
|
|
75
|
+
"@@QUIT",
|
|
76
|
+
"@@NAVIGATE",
|
|
77
|
+
"@@HOT_RELOAD",
|
|
78
|
+
"@@PIPELINE_START",
|
|
79
|
+
"@@PIPELINE_COMPLETE",
|
|
80
|
+
"@@PHASE_START",
|
|
81
|
+
"@@PHASE_COMPLETE",
|
|
82
|
+
"@@PHASE_FAILED",
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# App
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AppStatus(Enum):
|
|
93
|
+
IDLE = auto()
|
|
94
|
+
RUNNING = auto()
|
|
95
|
+
PAUSED = auto()
|
|
96
|
+
STOPPED = auto()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class RenderTarget(Enum):
|
|
100
|
+
TERMINAL = auto()
|
|
101
|
+
HTML = auto()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Screens / Flows
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class Screen:
|
|
111
|
+
"""Named screen config: template name + reducer reference."""
|
|
112
|
+
|
|
113
|
+
name: str
|
|
114
|
+
template: str
|
|
115
|
+
reducer: Callable # Reducer protocol
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True)
|
|
119
|
+
class Transition:
|
|
120
|
+
"""Flow edge between screens."""
|
|
121
|
+
|
|
122
|
+
from_screen: str
|
|
123
|
+
to_screen: str
|
|
124
|
+
on_action: str
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Fields / Forms
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class FieldType(Enum):
|
|
133
|
+
TEXT = auto()
|
|
134
|
+
SELECT = auto()
|
|
135
|
+
CONFIRM = auto()
|
|
136
|
+
PASSWORD = auto()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True, slots=True)
|
|
140
|
+
class FieldSpec:
|
|
141
|
+
"""Declarative field configuration."""
|
|
142
|
+
|
|
143
|
+
name: str
|
|
144
|
+
label: str
|
|
145
|
+
field_type: FieldType = FieldType.TEXT
|
|
146
|
+
choices: tuple[str, ...] = ()
|
|
147
|
+
default: Any = None
|
|
148
|
+
validator: Callable | None = None
|
|
149
|
+
placeholder: str = ""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True, slots=True)
|
|
153
|
+
class FieldState:
|
|
154
|
+
"""Runtime state for a single field."""
|
|
155
|
+
|
|
156
|
+
value: Any = ""
|
|
157
|
+
cursor: int = 0
|
|
158
|
+
error: str = ""
|
|
159
|
+
focused: bool = False
|
|
160
|
+
selected_index: int = 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True, slots=True)
|
|
164
|
+
class FormState:
|
|
165
|
+
"""Full form state."""
|
|
166
|
+
|
|
167
|
+
fields: tuple[FieldState, ...] = ()
|
|
168
|
+
specs: tuple[FieldSpec, ...] = ()
|
|
169
|
+
active_index: int = 0
|
|
170
|
+
submitted: bool = False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# Effects (sagas)
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(frozen=True, slots=True)
|
|
179
|
+
class Call:
|
|
180
|
+
"""Call a function, resume saga with its return value."""
|
|
181
|
+
|
|
182
|
+
fn: Callable
|
|
183
|
+
args: tuple = ()
|
|
184
|
+
kwargs: dict = field(default_factory=dict)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True, slots=True)
|
|
188
|
+
class Put:
|
|
189
|
+
"""Dispatch an action back to the store."""
|
|
190
|
+
|
|
191
|
+
action: Action
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True, slots=True)
|
|
195
|
+
class Select:
|
|
196
|
+
"""Read current state, resume saga with it."""
|
|
197
|
+
|
|
198
|
+
selector: Callable | None = None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True, slots=True)
|
|
202
|
+
class Fork:
|
|
203
|
+
"""Run another saga concurrently."""
|
|
204
|
+
|
|
205
|
+
saga: Callable | Generator
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass(frozen=True, slots=True)
|
|
209
|
+
class Delay:
|
|
210
|
+
"""Sleep for N seconds."""
|
|
211
|
+
|
|
212
|
+
seconds: float
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# Reducer result (state + optional sagas)
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass(frozen=True, slots=True)
|
|
221
|
+
class ReducerResult:
|
|
222
|
+
"""Reducer can return this to trigger side effects."""
|
|
223
|
+
|
|
224
|
+
state: Any
|
|
225
|
+
sagas: tuple[Callable, ...] = ()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass(frozen=True, slots=True)
|
|
229
|
+
class Quit:
|
|
230
|
+
"""Signal the app to exit. Return from a reducer to stop the event loop."""
|
|
231
|
+
|
|
232
|
+
state: Any
|
|
233
|
+
code: int = 0
|
|
234
|
+
sagas: tuple[Callable, ...] = ()
|
milo/app.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""App event loop and terminal rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from milo._errors import AppError, ErrorCode, format_render_error
|
|
16
|
+
from milo._types import Action, AppStatus, RenderTarget
|
|
17
|
+
from milo.flow import Flow, FlowState
|
|
18
|
+
from milo.input._platform import is_tty
|
|
19
|
+
from milo.input._reader import KeyReader
|
|
20
|
+
from milo.state import Store
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _TerminalRenderer:
|
|
24
|
+
"""In-place terminal renderer using alternate screen buffer.
|
|
25
|
+
|
|
26
|
+
Uses cursor-home redraws with line clearing to avoid flicker
|
|
27
|
+
and prevent frame stacking.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._prev_lines = 0
|
|
32
|
+
self._started = False
|
|
33
|
+
|
|
34
|
+
def start(self) -> None:
|
|
35
|
+
"""Enter alternate screen buffer and hide cursor."""
|
|
36
|
+
sys.stdout.write("\033[?1049h") # Enter alternate screen
|
|
37
|
+
sys.stdout.write("\033[?25l") # Hide cursor
|
|
38
|
+
sys.stdout.flush()
|
|
39
|
+
self._started = True
|
|
40
|
+
|
|
41
|
+
def update(self, output: str) -> None:
|
|
42
|
+
"""Redraw the screen with new output."""
|
|
43
|
+
if not self._started:
|
|
44
|
+
return
|
|
45
|
+
cols = shutil.get_terminal_size().columns
|
|
46
|
+
lines = output.split("\n")
|
|
47
|
+
|
|
48
|
+
# Move cursor to home position
|
|
49
|
+
sys.stdout.write("\033[H")
|
|
50
|
+
|
|
51
|
+
# Write each line, clearing to end of line
|
|
52
|
+
for line in lines:
|
|
53
|
+
# Truncate to terminal width to avoid wrapping artifacts
|
|
54
|
+
sys.stdout.write(line[:cols])
|
|
55
|
+
sys.stdout.write("\033[K\n") # Clear to end of line
|
|
56
|
+
|
|
57
|
+
# Clear any leftover lines from previous frame
|
|
58
|
+
if self._prev_lines > len(lines):
|
|
59
|
+
for _ in range(self._prev_lines - len(lines)):
|
|
60
|
+
sys.stdout.write("\033[K\n")
|
|
61
|
+
|
|
62
|
+
self._prev_lines = len(lines)
|
|
63
|
+
sys.stdout.flush()
|
|
64
|
+
|
|
65
|
+
def stop(self) -> None:
|
|
66
|
+
"""Show cursor and leave alternate screen buffer."""
|
|
67
|
+
if not self._started:
|
|
68
|
+
return
|
|
69
|
+
self._started = False
|
|
70
|
+
sys.stdout.write("\033[?25h") # Show cursor
|
|
71
|
+
sys.stdout.write("\033[?1049l") # Leave alternate screen
|
|
72
|
+
sys.stdout.flush()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class App:
|
|
76
|
+
"""Main application event loop.
|
|
77
|
+
|
|
78
|
+
Integrates the Store, KeyReader, and kida LiveRenderer
|
|
79
|
+
into a unified event loop.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
*,
|
|
85
|
+
template: str | Any = "",
|
|
86
|
+
reducer: Callable | None = None,
|
|
87
|
+
initial_state: Any = None,
|
|
88
|
+
middleware: tuple[Callable, ...] = (),
|
|
89
|
+
tick_rate: float = 0.0,
|
|
90
|
+
transient: bool = False,
|
|
91
|
+
target: RenderTarget = RenderTarget.TERMINAL,
|
|
92
|
+
record: bool | str | Path = False,
|
|
93
|
+
env: Any = None,
|
|
94
|
+
flow: Flow | None = None,
|
|
95
|
+
exit_template: str = "",
|
|
96
|
+
) -> None:
|
|
97
|
+
self._target = target
|
|
98
|
+
self._tick_rate = tick_rate
|
|
99
|
+
self._transient = transient
|
|
100
|
+
self._env = env
|
|
101
|
+
self._flow = flow
|
|
102
|
+
self._template_name = template
|
|
103
|
+
self._exit_template = exit_template
|
|
104
|
+
self._status = AppStatus.IDLE
|
|
105
|
+
self._stop = threading.Event()
|
|
106
|
+
|
|
107
|
+
# Flow mode: build reducer from flow
|
|
108
|
+
if flow is not None:
|
|
109
|
+
self._reducer = flow.build_reducer()
|
|
110
|
+
self._initial_state = None
|
|
111
|
+
self._template_map = flow.template_map
|
|
112
|
+
else:
|
|
113
|
+
if reducer is None:
|
|
114
|
+
raise AppError(ErrorCode.APP_LIFECYCLE, "Either reducer or flow is required")
|
|
115
|
+
self._reducer = reducer
|
|
116
|
+
self._initial_state = initial_state
|
|
117
|
+
self._template_map = None
|
|
118
|
+
|
|
119
|
+
self._middleware = middleware
|
|
120
|
+
self._record = record
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def from_flow(cls, flow: Flow, **kwargs: Any) -> App:
|
|
124
|
+
"""Create App from a declarative Flow."""
|
|
125
|
+
return cls(flow=flow, **kwargs)
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def render(cls, template: str, state: Any = None, *, env: Any = None) -> str:
|
|
129
|
+
"""One-shot render of a template with state. Returns the rendered string."""
|
|
130
|
+
if env is None:
|
|
131
|
+
from milo.templates import get_env
|
|
132
|
+
|
|
133
|
+
env = get_env()
|
|
134
|
+
tmpl = env.get_template(template)
|
|
135
|
+
return tmpl.render(state=state)
|
|
136
|
+
|
|
137
|
+
def run(self) -> Any:
|
|
138
|
+
"""Run the event loop. Returns final state."""
|
|
139
|
+
store = Store(
|
|
140
|
+
self._reducer,
|
|
141
|
+
self._initial_state,
|
|
142
|
+
self._middleware,
|
|
143
|
+
record=self._record,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if self._target == RenderTarget.HTML or not is_tty():
|
|
147
|
+
# Single render pass, no input
|
|
148
|
+
self._render_once(store.state)
|
|
149
|
+
if self._exit_template:
|
|
150
|
+
env = self._get_env()
|
|
151
|
+
try:
|
|
152
|
+
self._render_exit(store.state, env)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
msg = format_render_error(e, template_name=self._exit_template, env=env)
|
|
155
|
+
sys.stderr.write(f"[milo] {msg}\n")
|
|
156
|
+
return store.state
|
|
157
|
+
|
|
158
|
+
self._status = AppStatus.RUNNING
|
|
159
|
+
self._stop.clear()
|
|
160
|
+
|
|
161
|
+
# Set up signal handler for resize
|
|
162
|
+
original_sigwinch = None
|
|
163
|
+
if hasattr(signal, "SIGWINCH"):
|
|
164
|
+
|
|
165
|
+
def _on_resize(signum: int, frame: Any) -> None:
|
|
166
|
+
try:
|
|
167
|
+
cols, rows = os.get_terminal_size()
|
|
168
|
+
store.dispatch(Action("@@RESIZE", payload=(cols, rows)))
|
|
169
|
+
except OSError:
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
original_sigwinch = signal.getsignal(signal.SIGWINCH)
|
|
173
|
+
signal.signal(signal.SIGWINCH, _on_resize)
|
|
174
|
+
|
|
175
|
+
# Set up tick timer
|
|
176
|
+
tick_thread = None
|
|
177
|
+
if self._tick_rate > 0:
|
|
178
|
+
stop_tick = threading.Event()
|
|
179
|
+
|
|
180
|
+
def _tick_loop() -> None:
|
|
181
|
+
while not stop_tick.is_set():
|
|
182
|
+
stop_tick.wait(self._tick_rate)
|
|
183
|
+
if not stop_tick.is_set() and not self._stop.is_set():
|
|
184
|
+
store.dispatch(Action("@@TICK"))
|
|
185
|
+
|
|
186
|
+
tick_thread = threading.Thread(target=_tick_loop, daemon=True)
|
|
187
|
+
tick_thread.start()
|
|
188
|
+
|
|
189
|
+
env = self._get_env()
|
|
190
|
+
renderer = _TerminalRenderer()
|
|
191
|
+
quit_dispatched = False
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
renderer.start()
|
|
195
|
+
|
|
196
|
+
# Subscribe to state changes for re-rendering
|
|
197
|
+
def _on_state_change() -> None:
|
|
198
|
+
self._render_state(store.state, env, renderer)
|
|
199
|
+
|
|
200
|
+
unsubscribe = store.subscribe(_on_state_change)
|
|
201
|
+
|
|
202
|
+
# Initial render
|
|
203
|
+
self._render_state(store.state, env, renderer)
|
|
204
|
+
|
|
205
|
+
# Input loop
|
|
206
|
+
with KeyReader() as keys:
|
|
207
|
+
for key in keys:
|
|
208
|
+
if self._stop.is_set() or store.quit_requested:
|
|
209
|
+
break
|
|
210
|
+
|
|
211
|
+
# Ctrl+C: first dispatches @@QUIT, second force-exits
|
|
212
|
+
if key.ctrl and key.char == "c":
|
|
213
|
+
if quit_dispatched:
|
|
214
|
+
break
|
|
215
|
+
quit_dispatched = True
|
|
216
|
+
store.dispatch(Action("@@QUIT"))
|
|
217
|
+
if store.quit_requested:
|
|
218
|
+
break
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
store.dispatch(Action("@@KEY", payload=key))
|
|
222
|
+
|
|
223
|
+
if store.quit_requested:
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
self._status = AppStatus.STOPPED
|
|
227
|
+
self._stop.set()
|
|
228
|
+
unsubscribe()
|
|
229
|
+
|
|
230
|
+
finally:
|
|
231
|
+
if tick_thread is not None:
|
|
232
|
+
stop_tick.set()
|
|
233
|
+
with contextlib.suppress(Exception):
|
|
234
|
+
renderer.stop()
|
|
235
|
+
if original_sigwinch is not None:
|
|
236
|
+
signal.signal(signal.SIGWINCH, original_sigwinch)
|
|
237
|
+
store.shutdown()
|
|
238
|
+
|
|
239
|
+
final_state = store.state
|
|
240
|
+
|
|
241
|
+
# Render exit template if provided
|
|
242
|
+
if self._exit_template:
|
|
243
|
+
try:
|
|
244
|
+
self._render_exit(final_state, env)
|
|
245
|
+
except Exception as e:
|
|
246
|
+
msg = format_render_error(e, template_name=self._exit_template, env=env)
|
|
247
|
+
sys.stderr.write(f"[milo] {msg}\n")
|
|
248
|
+
|
|
249
|
+
return final_state
|
|
250
|
+
|
|
251
|
+
def _get_env(self) -> Any:
|
|
252
|
+
"""Get or create the kida Environment."""
|
|
253
|
+
if self._env is not None:
|
|
254
|
+
return self._env
|
|
255
|
+
from milo.templates import get_env
|
|
256
|
+
|
|
257
|
+
return get_env()
|
|
258
|
+
|
|
259
|
+
def _get_template_name(self, state: Any) -> str:
|
|
260
|
+
"""Get the template name for the current state."""
|
|
261
|
+
if self._template_map and isinstance(state, FlowState):
|
|
262
|
+
return self._template_map.get(state.current_screen, self._template_name)
|
|
263
|
+
return self._template_name
|
|
264
|
+
|
|
265
|
+
def _render_state(self, state: Any, env: Any, renderer: _TerminalRenderer) -> None:
|
|
266
|
+
"""Render current state through the template."""
|
|
267
|
+
try:
|
|
268
|
+
template_name = self._get_template_name(state)
|
|
269
|
+
template = env.get_template(template_name)
|
|
270
|
+
|
|
271
|
+
# For flow state, pass the current screen's state
|
|
272
|
+
render_state = state
|
|
273
|
+
if isinstance(state, FlowState):
|
|
274
|
+
render_state = state.screen_states.get(state.current_screen, state)
|
|
275
|
+
|
|
276
|
+
output = template.render(state=render_state)
|
|
277
|
+
renderer.update(output)
|
|
278
|
+
except Exception as e:
|
|
279
|
+
template_name = self._get_template_name(state)
|
|
280
|
+
msg = format_render_error(e, template_name=template_name, env=env)
|
|
281
|
+
sys.stderr.write(f"[milo] {msg}\n")
|
|
282
|
+
|
|
283
|
+
def _render_exit(self, state: Any, env: Any) -> None:
|
|
284
|
+
"""Render the exit template once to stdout."""
|
|
285
|
+
template = env.get_template(self._exit_template)
|
|
286
|
+
render_state = state
|
|
287
|
+
if isinstance(state, FlowState):
|
|
288
|
+
# For flows, pass all screen states so exit template can reference any data
|
|
289
|
+
render_state = state.screen_states
|
|
290
|
+
output = template.render(state=render_state)
|
|
291
|
+
sys.stdout.write(output + "\n")
|
|
292
|
+
sys.stdout.flush()
|
|
293
|
+
|
|
294
|
+
def _render_once(self, state: Any) -> None:
|
|
295
|
+
"""Single render pass (non-TTY or HTML mode)."""
|
|
296
|
+
try:
|
|
297
|
+
env = self._get_env()
|
|
298
|
+
template_name = self._get_template_name(state)
|
|
299
|
+
template = env.get_template(template_name)
|
|
300
|
+
|
|
301
|
+
render_state = state
|
|
302
|
+
if isinstance(state, FlowState):
|
|
303
|
+
render_state = state.screen_states.get(state.current_screen, state)
|
|
304
|
+
|
|
305
|
+
output = template.render(state=render_state)
|
|
306
|
+
sys.stdout.write(output + "\n")
|
|
307
|
+
sys.stdout.flush()
|
|
308
|
+
except Exception as e:
|
|
309
|
+
template_name = self._get_template_name(state)
|
|
310
|
+
msg = format_render_error(e, template_name=template_name)
|
|
311
|
+
sys.stderr.write(f"[milo] {msg}\n")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def run(*, template: str, reducer: Callable, initial_state: Any, **kwargs: Any) -> Any:
|
|
315
|
+
"""Shorthand: App(...).run()"""
|
|
316
|
+
return App(template=template, reducer=reducer, initial_state=initial_state, **kwargs).run()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def render_html(
|
|
320
|
+
state: Any,
|
|
321
|
+
template: str | Any,
|
|
322
|
+
*,
|
|
323
|
+
title: str = "",
|
|
324
|
+
css: str = "",
|
|
325
|
+
env: Any = None,
|
|
326
|
+
) -> str:
|
|
327
|
+
"""One-shot HTML render of state through template."""
|
|
328
|
+
if env is None:
|
|
329
|
+
from milo.templates import get_env
|
|
330
|
+
|
|
331
|
+
env = get_env(autoescape=True)
|
|
332
|
+
|
|
333
|
+
tmpl = env.get_template(template) if isinstance(template, str) else template
|
|
334
|
+
|
|
335
|
+
body = tmpl.render(state=state)
|
|
336
|
+
|
|
337
|
+
default_css = """
|
|
338
|
+
body { background: #1e1e1e; color: #d4d4d4; font-family: monospace; padding: 2em; }
|
|
339
|
+
.dim { opacity: 0.6; }
|
|
340
|
+
strong { font-weight: bold; }
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
return f"""<!DOCTYPE html>
|
|
344
|
+
<html>
|
|
345
|
+
<head>
|
|
346
|
+
<meta charset="utf-8">
|
|
347
|
+
<title>{title}</title>
|
|
348
|
+
<style>{css or default_css}</style>
|
|
349
|
+
</head>
|
|
350
|
+
<body>
|
|
351
|
+
<pre>{body}</pre>
|
|
352
|
+
</body>
|
|
353
|
+
</html>"""
|