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.
- python_xli-0.2.0.dist-info/METADATA +397 -0
- python_xli-0.2.0.dist-info/RECORD +22 -0
- python_xli-0.2.0.dist-info/WHEEL +4 -0
- python_xli-0.2.0.dist-info/licenses/LICENSE +21 -0
- xli/__init__.py +38 -0
- xli/approval.py +14 -0
- xli/cells.py +389 -0
- xli/engine.py +868 -0
- xli/images.py +90 -0
- xli/pets.py +27 -0
- xli/render/__init__.py +21 -0
- xli/render/diff.py +37 -0
- xli/render/message.py +115 -0
- xli/render/plan.py +70 -0
- xli/render/reasoning.py +20 -0
- xli/render/tool.py +128 -0
- xli/render_bridge.py +36 -0
- xli/slash.py +154 -0
- xli/status.py +59 -0
- xli/theme.py +153 -0
- xli/ui.py +346 -0
- xli/wizard.py +46 -0
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()
|