python-xli 0.2.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.
xli/ui.py ADDED
@@ -0,0 +1,346 @@
1
+ """The :class:`UI` — xli's one public entry point.
2
+
3
+ Idioms (one way to do each thing, Pythonic):
4
+
5
+ * **Register handlers with decorators.** ``@ui.on_prompt``, ``@ui.command(name)``,
6
+ ``@ui.renderer(name)``, ``@ui.on_interrupt``.
7
+ * **Stream with a context manager.** ``with ui.streaming("assistant") as out: ...``
8
+ * **Mutate cards with handles.** ``card = ui.tool(...); card.update(status="done")``
9
+ * **Show work with a spinner.** ``with ui.working("thinking"): ...``
10
+ * **Approve / pick with await.** ``await ui.approve(...)`` / ``await ui.pick(...)``
11
+ * **Run with ``ui.run()``.** That's it.
12
+
13
+ The transcript is inline: finalized cells live in your terminal's normal scrollback
14
+ (selectable, scrollable); only the active tail at the bottom redraws. Nothing in this
15
+ module knows about LLMs or agents — you decide what the events mean.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import inspect
21
+ from collections.abc import Awaitable, Callable, Iterable, Sequence
22
+ from typing import Any
23
+
24
+ from rich.console import RenderableType
25
+ from rich.text import Text
26
+
27
+ from .approval import Decision
28
+ from .cells import (
29
+ Cell,
30
+ CustomCell,
31
+ DiffCell,
32
+ ImageCell,
33
+ MessageCell,
34
+ NoteCell,
35
+ PlanCell,
36
+ ReasoningCell,
37
+ StreamingCell,
38
+ ToolCell,
39
+ )
40
+ from .engine import Engine
41
+ from .pets import frames as pet_frames
42
+ from .slash import Handler, SlashCommand, SlashRegistry
43
+ from .status import StatusBar
44
+ from .theme import Theme, ThemeName, resolve
45
+ from .wizard import Step
46
+ from .wizard import step as _step
47
+
48
+ PromptHandler = Callable[[str], Awaitable[None] | None]
49
+ EventRenderer = Callable[["UI", Any], None]
50
+ InterruptHandler = Callable[[], Awaitable[None] | None]
51
+
52
+
53
+ class UI:
54
+ """The xli app. Construct, register handlers, call ``ui.run()``."""
55
+
56
+ def __init__(
57
+ self,
58
+ *,
59
+ title: str | None = None,
60
+ intro: str | None = None,
61
+ theme: Theme | ThemeName | None = None,
62
+ status_fields: Sequence[str] = (),
63
+ history_file: str | None = None,
64
+ slash_commands: Iterable[SlashCommand] = (),
65
+ pet: str | Sequence[str] | None = None,
66
+ notify_after: float | None = None,
67
+ ) -> None:
68
+ self.title = title
69
+ self.theme: Theme = resolve(theme)
70
+ self.status = StatusBar(fields=status_fields, theme=self.theme)
71
+ self._slash = SlashRegistry()
72
+ for cmd in slash_commands:
73
+ self._slash.register(cmd)
74
+ self._prompt_handler: PromptHandler | None = None
75
+ self._interrupt_handler: InterruptHandler | None = None
76
+ self._renderers: dict[str, EventRenderer] = {}
77
+ self._register_builtin_commands()
78
+ self._engine = Engine(
79
+ theme=self.theme, slash=self._slash, status=self.status,
80
+ title=title, intro=intro, history_file=history_file,
81
+ pet=pet_frames(pet), notify_after=notify_after,
82
+ )
83
+
84
+ # ----------------------------------------------------- decorators
85
+ def on_prompt(self, fn: PromptHandler) -> PromptHandler:
86
+ """Register the handler called once per user submission (raw prompt text)."""
87
+ self._prompt_handler = fn
88
+ return fn
89
+
90
+ def on_interrupt(self, fn: InterruptHandler) -> InterruptHandler:
91
+ """Register a cleanup hook fired when the user interrupts a turn (ESC).
92
+
93
+ For releasing resources — it should not write to the transcript (the library
94
+ already emits a single interrupt marker).
95
+ """
96
+ self._interrupt_handler = fn
97
+ return fn
98
+
99
+ def command(
100
+ self, name: str, *, description: str = "", aliases: Sequence[str] = (),
101
+ ) -> Callable[[Handler], Handler]:
102
+ """Register a slash command: ``@ui.command("model", description=...)``."""
103
+ def decorator(fn: Handler) -> Handler:
104
+ self._slash.register(
105
+ SlashCommand(name=name, handler=fn, description=description, aliases=tuple(aliases))
106
+ )
107
+ return fn
108
+ return decorator
109
+
110
+ def renderer(self, event_type: str) -> Callable[[EventRenderer], EventRenderer]:
111
+ """Register a custom renderer for events dispatched via ``ui.dispatch``."""
112
+ def decorator(fn: EventRenderer) -> EventRenderer:
113
+ self._renderers[event_type] = fn
114
+ return fn
115
+ return decorator
116
+
117
+ # ----------------------------------------------------- transcript API
118
+ def print(self, renderable: RenderableType) -> Cell:
119
+ """Append any Rich renderable to the transcript. Returns its cell handle."""
120
+ return self._engine.emit(CustomCell(renderable), live=False)
121
+
122
+ def message(self, role: str, text: str, *, markdown: bool | None = None) -> Cell:
123
+ """One-shot message. Returns a handle you can ``.update(text=...)``."""
124
+ return self._engine.emit(
125
+ MessageCell(_normalize_role(role), text, markdown=markdown), live=False
126
+ )
127
+
128
+ def header(self, text: str) -> Cell:
129
+ """A single muted banner line for runtime context (e.g. ``app · model · mode``)."""
130
+ return self._engine.emit(CustomCell(Text(text, style=self.theme.muted_color)), live=False)
131
+
132
+ def note(self, text: str) -> Cell:
133
+ """Single muted ``· line`` — good for status updates."""
134
+ return self._engine.emit(NoteCell(text), live=False)
135
+
136
+ def tool(
137
+ self,
138
+ name: str,
139
+ *,
140
+ args: dict[str, Any] | None = None,
141
+ output: Any = None,
142
+ error: str | None = None,
143
+ status: str | None = None,
144
+ ) -> ToolCell:
145
+ """A tool-call card. Pass ``status="running"`` to keep it live + mutable; it
146
+ commits to scrollback when you ``.update(status="done")`` (or "error"/"cancelled").
147
+ Omit ``status`` for a one-shot card."""
148
+ cell = ToolCell(name, args=args, output=output, error=error, status=status)
149
+ return self._engine.emit(cell, live=status == "running") # type: ignore[return-value]
150
+
151
+ def diff(self, diff: str, *, path: str | None = None) -> Cell:
152
+ return self._engine.emit(DiffCell(diff, path=path), live=False)
153
+
154
+ def plan(self, steps: Iterable[Any], *, title: str | None = None) -> Cell:
155
+ return self._engine.emit(PlanCell(steps, title=title), live=False)
156
+
157
+ def reasoning(self, summary: str) -> Cell:
158
+ return self._engine.emit(ReasoningCell(summary), live=False)
159
+
160
+ def image(self, source: Any, *, width_cols: int = 48) -> Cell:
161
+ """Render an inline image (path / bytes / PIL image) as a transcript cell.
162
+
163
+ Uses the terminal's graphics protocol (kitty / iTerm2) when available, else a
164
+ half-block fallback. See ``XLI_IMAGE_PROTOCOL`` to override detection.
165
+ """
166
+ return self._engine.emit(ImageCell(source, width_cols=width_cols), live=False)
167
+
168
+ def link(self, label: str, url: str) -> Cell:
169
+ """Commit a clickable hyperlink (OSC 8) line to the transcript."""
170
+ return self.print(Text(label, style=f"link {url} {self.theme.user_color}"))
171
+
172
+ def notify(self, message: str, *, title: str | None = None) -> None:
173
+ """Fire a desktop notification (OSC 9 / OSC 777)."""
174
+ self._engine.notify(message, title)
175
+
176
+ def streaming(self, role: str, *, markdown: bool | None = None) -> Streaming:
177
+ """Context manager for streamed text. Renders live, commits on exit::
178
+
179
+ with ui.streaming("assistant") as out:
180
+ for chunk in chunks:
181
+ out.write(chunk)
182
+ """
183
+ return Streaming(self, role=_normalize_role(role), markdown=markdown)
184
+
185
+ def working(self, label: str = "working"):
186
+ """Context manager showing an animated spinner in the live tail while the block runs."""
187
+ return self._engine.spinner(label)
188
+
189
+ # ----------------------------------------------------- approvals + modals
190
+ async def approve(self, *, title: str, body: str = "", reason: str = "") -> Decision:
191
+ """Inline approval prompt. Blocks until y / a / n / esc."""
192
+ return await self._engine.approve(title=title, body=body, reason=reason)
193
+
194
+ async def confirm(self, question: str, *, title: str = "Confirm") -> bool:
195
+ return await self._engine.confirm(question)
196
+
197
+ async def input(self, question: str, *, title: str = "Input", default: str = "") -> str | None:
198
+ prompt = question if not default else f"{question} [{default}]"
199
+ text = await self._engine.capture_line(f"{prompt} (enter)")
200
+ if text is None:
201
+ return None
202
+ return text or default
203
+
204
+ async def pick(self, title: str, items: Sequence[str | tuple[str, str]]) -> str | None:
205
+ """Arrow-selectable picker (↑/↓ · 1-9 · enter · esc). Returns the chosen key
206
+ (or the item itself for plain strings), or None if cancelled."""
207
+ options = [it if isinstance(it, tuple) else (it, it) for it in items]
208
+ return await self._engine.choose(title, options)
209
+
210
+ #: Build wizard steps: ``ui.step.pick(...) / .confirm(...) / .text(...)``.
211
+ step = _step
212
+
213
+ async def wizard(self, steps: Iterable[Step]) -> dict[str, Any] | None:
214
+ """Run a sequence of steps; return {key: answer}, or None if any step is cancelled."""
215
+ answers: dict[str, Any] = {}
216
+ for s in steps:
217
+ if s.kind == "pick":
218
+ ans: Any = await self.pick(s.prompt, s.options)
219
+ elif s.kind == "confirm":
220
+ ans = await self.confirm(s.prompt)
221
+ else:
222
+ ans = await self.input(s.prompt, default=s.default)
223
+ if ans is None:
224
+ return None
225
+ answers[s.key] = ans
226
+ return answers
227
+
228
+ # ----------------------------------------------------- generic dispatch
229
+ def dispatch(self, event: Any) -> None:
230
+ """Route an event to a registered ``@ui.renderer``; falls back to printing repr."""
231
+ kind = event["type"] if isinstance(event, dict) else getattr(event, "type", None)
232
+ fn = self._renderers.get(kind) if kind else None
233
+ if fn is None:
234
+ self.print(Text(repr(event)))
235
+ return
236
+ fn(self, event)
237
+
238
+ # ----------------------------------------------------- lifecycle
239
+ def clear_transcript(self) -> None:
240
+ """Clear the visible screen (terminal scrollback may retain history)."""
241
+ self._engine.committed.clear()
242
+ self._engine.live.clear()
243
+ print("\033[2J\033[3J\033[H", end="", flush=True)
244
+
245
+ def exit(self) -> None:
246
+ """Stop the run loop."""
247
+ self._engine.exit()
248
+
249
+ def run(self) -> None:
250
+ """Block on the event loop until the user quits."""
251
+ if self._prompt_handler is None:
252
+ raise RuntimeError(
253
+ "No @ui.on_prompt handler registered. Did you forget the decorator?"
254
+ )
255
+ self._engine.set_handler(self._handle_one)
256
+ if self._interrupt_handler is not None:
257
+ self._engine.set_on_interrupt(self._interrupt_async)
258
+ self._engine.run()
259
+
260
+ async def _interrupt_async(self) -> None:
261
+ await _maybe_await(self._interrupt_handler()) # type: ignore[misc]
262
+
263
+ async def _handle_one(self, prompt: str) -> None:
264
+ if prompt.startswith("/"):
265
+ cmd, args = self._slash.parse(prompt)
266
+ if cmd is None:
267
+ self.note(f"unknown command: {prompt.split()[0]}")
268
+ return
269
+ await _maybe_await(cmd.handler(self, args))
270
+ return
271
+ self.message("user", prompt) # echo (the composer clears on submit)
272
+ assert self._prompt_handler is not None
273
+ await _maybe_await(self._prompt_handler(prompt))
274
+
275
+ # ----------------------------------------------------- builtin commands
276
+ def _register_builtin_commands(self) -> None:
277
+ async def cmd_help(ui: UI, args: str) -> None:
278
+ ui._print_help()
279
+
280
+ async def cmd_quit(ui: UI, args: str) -> None:
281
+ ui.exit()
282
+
283
+ async def cmd_clear(ui: UI, args: str) -> None:
284
+ ui.clear_transcript()
285
+
286
+ self._slash.register(SlashCommand("help", description="show commands", handler=cmd_help, aliases=("?",)))
287
+ self._slash.register(SlashCommand("quit", description="exit", handler=cmd_quit, aliases=("q", "exit")))
288
+ self._slash.register(SlashCommand("clear", description="clear the transcript", handler=cmd_clear))
289
+
290
+ def _print_help(self) -> None:
291
+ lines = ["commands"]
292
+ for cmd in self._slash.all():
293
+ alias = f" ({', '.join('/' + a for a in cmd.aliases)})" if cmd.aliases else ""
294
+ lines.append(f" /{cmd.name:<14} {cmd.description}{alias}")
295
+ lines += [
296
+ "", "keys",
297
+ " enter — send · alt+enter / ctrl+j — newline",
298
+ " esc — interrupt the current turn · ctrl+d — quit",
299
+ " y / a / n — accept / always / deny when an approval is active",
300
+ ]
301
+ self.print(Text("\n".join(lines), style=self.theme.muted_color))
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # Streaming context manager
306
+ # ---------------------------------------------------------------------------
307
+
308
+
309
+ class Streaming:
310
+ """Yielded by :meth:`UI.streaming`. Live while open, commits to scrollback on exit."""
311
+
312
+ def __init__(self, ui: UI, *, role: str, markdown: bool | None) -> None:
313
+ self._cell = StreamingCell(role, markdown=markdown)
314
+ self._engine = ui._engine
315
+
316
+ def __enter__(self) -> Streaming:
317
+ self._engine.emit(self._cell, live=True)
318
+ return self
319
+
320
+ def __exit__(self, exc_type, exc, tb) -> None:
321
+ self._cell.close()
322
+
323
+ def write(self, chunk: str) -> None:
324
+ self._cell.append(chunk)
325
+
326
+ @property
327
+ def text(self) -> str:
328
+ return self._cell.text
329
+
330
+
331
+ # ---------------------------------------------------------------------------
332
+ # helpers
333
+ # ---------------------------------------------------------------------------
334
+
335
+
336
+ async def _maybe_await(value: Any) -> Any:
337
+ if inspect.isawaitable(value):
338
+ return await value
339
+ return value
340
+
341
+
342
+ def _normalize_role(role: str) -> str:
343
+ role = role.lower()
344
+ if role not in {"user", "assistant", "system"}:
345
+ return "system"
346
+ return role
xli/wizard.py ADDED
@@ -0,0 +1,46 @@
1
+ """Multi-step prompt flows — ``ui.wizard([...])``.
2
+
3
+ A wizard runs a sequence of steps (pick / confirm / text) and returns a dict of
4
+ answers keyed by each step's key (the prompt by default). Built on the same inline
5
+ picker + line-capture the standalone modals use, so a wizard looks like a natural
6
+ run of prompts in the transcript. Cancelling any step (esc) aborts the whole wizard
7
+ and returns ``None``.
8
+
9
+ answers = await ui.wizard([
10
+ ui.step.pick("Model", ["opus", "sonnet", "haiku"]),
11
+ ui.step.confirm("Enable telemetry?"),
12
+ ui.step.text("Project name", default="app"),
13
+ ])
14
+ # -> {"Model": "opus", "Enable telemetry?": True, "Project name": "myapp"}
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Sequence
20
+ from dataclasses import dataclass, field
21
+ from typing import Any
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Step:
26
+ kind: str # "pick" | "confirm" | "text"
27
+ prompt: str
28
+ key: str
29
+ options: list = field(default_factory=list)
30
+ default: str = ""
31
+
32
+
33
+ class _StepFactory:
34
+ """The ``ui.step`` namespace for building wizard steps."""
35
+
36
+ def pick(self, prompt: str, options: Sequence[Any], *, key: str | None = None) -> Step:
37
+ return Step("pick", prompt, key or prompt, options=list(options))
38
+
39
+ def confirm(self, prompt: str, *, key: str | None = None) -> Step:
40
+ return Step("confirm", prompt, key or prompt)
41
+
42
+ def text(self, prompt: str, *, default: str = "", key: str | None = None) -> Step:
43
+ return Step("text", prompt, key or prompt, default=default)
44
+
45
+
46
+ step = _StepFactory()