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/cells.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""The scene model — transcript cells and their live handles.
|
|
2
|
+
|
|
3
|
+
A **cell** is one retained transcript element (a message, a tool call, a diff, an
|
|
4
|
+
image, …). Every cell is *mutable* via the handle returned from the ``UI`` method
|
|
5
|
+
that created it::
|
|
6
|
+
|
|
7
|
+
card = ui.tool("shell", status="running")
|
|
8
|
+
...
|
|
9
|
+
card.update(status="done", output=result) # re-renders in place
|
|
10
|
+
card.remove() # drop it entirely
|
|
11
|
+
|
|
12
|
+
Cells are pure renderers: each turns its state into a Rich renderable (reusing the
|
|
13
|
+
``xli.render`` functions) and caches the ANSI lines per ``(version, width)`` so the
|
|
14
|
+
engine only re-renders what changed.
|
|
15
|
+
|
|
16
|
+
A cell lives in one of two tiers, managed by the engine:
|
|
17
|
+
|
|
18
|
+
* **live** — in the redrawing region at the bottom (mutable, animated).
|
|
19
|
+
* **committed** — printed into terminal scrollback (selectable, immutable).
|
|
20
|
+
|
|
21
|
+
A cell graduates from live to committed when it reports ``final`` (e.g. a tool card
|
|
22
|
+
reaching a terminal status, a stream closing). Messages/diffs/images are ``final``
|
|
23
|
+
on creation and commit immediately.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import time
|
|
29
|
+
from collections.abc import Iterable
|
|
30
|
+
from typing import Any, Protocol
|
|
31
|
+
|
|
32
|
+
from rich.console import Group, RenderableType
|
|
33
|
+
from rich.style import Style
|
|
34
|
+
from rich.text import Text
|
|
35
|
+
|
|
36
|
+
from .render import (
|
|
37
|
+
render_diff,
|
|
38
|
+
render_message,
|
|
39
|
+
render_plan,
|
|
40
|
+
render_reasoning,
|
|
41
|
+
render_tool,
|
|
42
|
+
)
|
|
43
|
+
from .render_bridge import render_to_ansi
|
|
44
|
+
from .theme import Theme
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CellSink(Protocol):
|
|
48
|
+
"""What a cell needs from its owning engine. The engine implements this."""
|
|
49
|
+
|
|
50
|
+
theme: Theme
|
|
51
|
+
|
|
52
|
+
def cell_changed(self, cell: Cell) -> None: ...
|
|
53
|
+
def cell_remove(self, cell: Cell) -> None: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Cell:
|
|
60
|
+
"""Base class: versioned, cached, mutable transcript element + handle."""
|
|
61
|
+
|
|
62
|
+
#: Cells that are ``final`` commit to scrollback; non-final cells stay live.
|
|
63
|
+
final: bool = True
|
|
64
|
+
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
self.version = 0
|
|
67
|
+
self._sink: CellSink | None = None
|
|
68
|
+
self._cache: tuple[int, int, list[str]] | None = None # (version, width, lines)
|
|
69
|
+
|
|
70
|
+
# -- subclass hook -----------------------------------------------------
|
|
71
|
+
def renderable(self, theme: Theme) -> RenderableType:
|
|
72
|
+
raise NotImplementedError
|
|
73
|
+
|
|
74
|
+
# -- rendering (cached) ------------------------------------------------
|
|
75
|
+
def lines(self, width: int, theme: Theme) -> list[str]:
|
|
76
|
+
if self._cache and self._cache[0] == self.version and self._cache[1] == width:
|
|
77
|
+
return self._cache[2]
|
|
78
|
+
out = render_to_ansi(self.renderable(theme), width)
|
|
79
|
+
self._cache = (self.version, width, out)
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
# -- public handle API -------------------------------------------------
|
|
83
|
+
def update(self, **fields: Any) -> Cell:
|
|
84
|
+
"""Mutate fields and re-render in place. Returns ``self`` for chaining."""
|
|
85
|
+
for key, value in fields.items():
|
|
86
|
+
setattr(self, key, value)
|
|
87
|
+
self.version += 1
|
|
88
|
+
if self._sink is not None:
|
|
89
|
+
self._sink.cell_changed(self)
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def remove(self) -> None:
|
|
93
|
+
"""Remove this cell from the transcript (only meaningful while live)."""
|
|
94
|
+
if self._sink is not None:
|
|
95
|
+
self._sink.cell_remove(self)
|
|
96
|
+
|
|
97
|
+
def _bump(self) -> None:
|
|
98
|
+
"""Internal: mark dirty + notify without setting attributes."""
|
|
99
|
+
self.version += 1
|
|
100
|
+
if self._sink is not None:
|
|
101
|
+
self._sink.cell_changed(self)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Concrete cells — thin wrappers over the pure xli.render functions
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class MessageCell(Cell):
|
|
110
|
+
def __init__(self, role: str, text: str, *, markdown: bool | None = None, label: bool = True) -> None:
|
|
111
|
+
super().__init__()
|
|
112
|
+
self.role, self.text, self.markdown, self.label = role, text, markdown, label
|
|
113
|
+
|
|
114
|
+
def renderable(self, theme: Theme) -> RenderableType:
|
|
115
|
+
return render_message(
|
|
116
|
+
self.text, role=self.role, theme=theme, markdown=self.markdown, label=self.label, # type: ignore[arg-type]
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class NoteCell(Cell):
|
|
121
|
+
"""A single muted line (``· text``) — status-y, no role label."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, text: str) -> None:
|
|
124
|
+
super().__init__()
|
|
125
|
+
self.text = text
|
|
126
|
+
|
|
127
|
+
def renderable(self, theme: Theme) -> RenderableType:
|
|
128
|
+
return Text(f"· {self.text}", style=theme.muted_color)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ToolCell(Cell):
|
|
132
|
+
TERMINAL = {"done", "error", "cancelled"}
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
name: str,
|
|
137
|
+
*,
|
|
138
|
+
args: dict[str, Any] | None = None,
|
|
139
|
+
output: Any = None,
|
|
140
|
+
error: str | None = None,
|
|
141
|
+
status: str | None = None,
|
|
142
|
+
) -> None:
|
|
143
|
+
super().__init__()
|
|
144
|
+
self.name, self.args, self.output, self.error, self.status = (
|
|
145
|
+
name, args, output, error, status,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def final(self) -> bool: # type: ignore[override]
|
|
150
|
+
# A one-shot card (no status) is final; a tracked card commits when it
|
|
151
|
+
# reaches a terminal status.
|
|
152
|
+
return self.status is None or self.status in self.TERMINAL
|
|
153
|
+
|
|
154
|
+
def renderable(self, theme: Theme) -> RenderableType:
|
|
155
|
+
return render_tool(
|
|
156
|
+
self.name, args=self.args, output=self.output,
|
|
157
|
+
error=self.error, status=self.status, theme=theme,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class DiffCell(Cell):
|
|
162
|
+
def __init__(self, diff: str, *, path: str | None = None) -> None:
|
|
163
|
+
super().__init__()
|
|
164
|
+
self.diff, self.path = diff, path
|
|
165
|
+
|
|
166
|
+
def renderable(self, theme: Theme) -> RenderableType:
|
|
167
|
+
return render_diff(self.diff, path=self.path, theme=theme)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class PlanCell(Cell):
|
|
171
|
+
def __init__(self, steps: Iterable[Any], *, title: str | None = None) -> None:
|
|
172
|
+
super().__init__()
|
|
173
|
+
self.steps, self.title = list(steps), title
|
|
174
|
+
|
|
175
|
+
def renderable(self, theme: Theme) -> RenderableType:
|
|
176
|
+
return render_plan(self.steps, title=self.title, theme=theme)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class ReasoningCell(Cell):
|
|
180
|
+
def __init__(self, summary: str) -> None:
|
|
181
|
+
super().__init__()
|
|
182
|
+
self.summary = summary
|
|
183
|
+
|
|
184
|
+
def renderable(self, theme: Theme) -> RenderableType:
|
|
185
|
+
return render_reasoning(self.summary, theme=theme)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class CustomCell(Cell):
|
|
189
|
+
"""Wraps an arbitrary Rich renderable (the ``ui.print`` escape hatch)."""
|
|
190
|
+
|
|
191
|
+
def __init__(self, renderable: RenderableType) -> None:
|
|
192
|
+
super().__init__()
|
|
193
|
+
self._renderable = renderable
|
|
194
|
+
|
|
195
|
+
def renderable(self, theme: Theme) -> RenderableType:
|
|
196
|
+
return self._renderable
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class ApprovalCell(Cell):
|
|
200
|
+
"""The *context* of an approval request — committed to scrollback so it persists
|
|
201
|
+
and the terminal auto-scrolls to it. The interactive choices + the outcome are
|
|
202
|
+
separate (a live prompt while deciding, then a committed result line)."""
|
|
203
|
+
|
|
204
|
+
def __init__(self, title: str, body: str = "", reason: str = "", choices: str = "") -> None:
|
|
205
|
+
super().__init__()
|
|
206
|
+
self.title, self.body, self.reason, self.choices = title, body, reason, choices
|
|
207
|
+
|
|
208
|
+
def renderable(self, theme: Theme) -> RenderableType:
|
|
209
|
+
t = Text()
|
|
210
|
+
t.append(f"{theme.approval_glyph} ", style=theme.approval_color)
|
|
211
|
+
t.append("approval needed", style=f"bold {theme.approval_color}")
|
|
212
|
+
t.append(f" {self.title}")
|
|
213
|
+
if self.body:
|
|
214
|
+
t.append(f"\n {self.body}")
|
|
215
|
+
if self.reason:
|
|
216
|
+
t.append(f"\n {self.reason}", style=theme.muted_color)
|
|
217
|
+
if self.choices:
|
|
218
|
+
t.append(f"\n {self.choices}", style=theme.approval_color)
|
|
219
|
+
return t
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class SpinnerCell(Cell):
|
|
223
|
+
"""Animated 'working' indicator. Lives in the live tail; never commits."""
|
|
224
|
+
|
|
225
|
+
final = False
|
|
226
|
+
FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
227
|
+
|
|
228
|
+
def __init__(self, label: str) -> None:
|
|
229
|
+
super().__init__()
|
|
230
|
+
self.label, self.frame = label, 0
|
|
231
|
+
self._start = time.monotonic()
|
|
232
|
+
|
|
233
|
+
def tick(self) -> None:
|
|
234
|
+
self.frame = (self.frame + 1) % len(self.FRAMES)
|
|
235
|
+
self.version += 1 # frame advance; the engine ticker drives the redraw
|
|
236
|
+
|
|
237
|
+
def renderable(self, theme: Theme) -> RenderableType:
|
|
238
|
+
spin = Text(self.FRAMES[self.frame], style=theme.warning_color)
|
|
239
|
+
suffix = f" {self.label}…"
|
|
240
|
+
elapsed = int(time.monotonic() - self._start)
|
|
241
|
+
if elapsed >= 1:
|
|
242
|
+
suffix += f" {elapsed}s"
|
|
243
|
+
return Text.assemble(spin, Text(suffix, style=theme.muted_color))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class StreamingCell(Cell):
|
|
247
|
+
"""A message that grows token-by-token while live, then commits on close.
|
|
248
|
+
|
|
249
|
+
For performance, finalized content is committed to scrollback as it completes (the
|
|
250
|
+
engine drains it via :meth:`take_committable_block`), so only the in-progress *tail*
|
|
251
|
+
is re-rendered each frame instead of the whole growing message. Boundaries are blank
|
|
252
|
+
lines that are not inside an open code fence — keeping each committed chunk's markdown
|
|
253
|
+
intact. The role label is emitted once (on the first committed/rendered chunk).
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
def __init__(self, role: str, *, markdown: bool | None = None) -> None:
|
|
257
|
+
super().__init__()
|
|
258
|
+
self.role, self.markdown = role, markdown
|
|
259
|
+
self.text = ""
|
|
260
|
+
self._closed = False
|
|
261
|
+
self._committed_len = 0
|
|
262
|
+
self._label_committed = False
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def final(self) -> bool: # type: ignore[override]
|
|
266
|
+
return self._closed
|
|
267
|
+
|
|
268
|
+
def append(self, chunk: str) -> None:
|
|
269
|
+
if not chunk:
|
|
270
|
+
return
|
|
271
|
+
self.text += chunk
|
|
272
|
+
self._bump()
|
|
273
|
+
|
|
274
|
+
def close(self) -> None:
|
|
275
|
+
self._closed = True
|
|
276
|
+
self._bump()
|
|
277
|
+
|
|
278
|
+
def consume_label(self) -> bool:
|
|
279
|
+
"""True the first time (this chunk shows the role label); False after."""
|
|
280
|
+
if self._label_committed:
|
|
281
|
+
return False
|
|
282
|
+
self._label_committed = True
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
def take_committable_block(self) -> str | None:
|
|
286
|
+
"""Pop the next finalized chunk (up to the last safe boundary), advancing the
|
|
287
|
+
committed offset. When closed, the entire remainder is committable. None if there
|
|
288
|
+
is nothing safe to commit yet."""
|
|
289
|
+
region = self.text[self._committed_len:]
|
|
290
|
+
if self._closed:
|
|
291
|
+
if not region:
|
|
292
|
+
return None
|
|
293
|
+
self._committed_len = len(self.text)
|
|
294
|
+
return region
|
|
295
|
+
boundary = _safe_boundary(region)
|
|
296
|
+
if boundary <= 0:
|
|
297
|
+
return None
|
|
298
|
+
self._committed_len += boundary
|
|
299
|
+
return region[:boundary]
|
|
300
|
+
|
|
301
|
+
def renderable(self, theme: Theme) -> RenderableType:
|
|
302
|
+
tail = self.text[self._committed_len:] or " "
|
|
303
|
+
return render_message(
|
|
304
|
+
tail, role=self.role, theme=theme, markdown=self.markdown, # type: ignore[arg-type]
|
|
305
|
+
label=not self._label_committed,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _safe_boundary(region: str) -> int:
|
|
310
|
+
"""Index up to which `region` can be committed: end of the last blank line that is
|
|
311
|
+
not inside an open code fence. 0 if none (keep buffering). Fences are balanced in
|
|
312
|
+
already-committed text, so we start outside a fence."""
|
|
313
|
+
in_fence = False
|
|
314
|
+
pos = 0
|
|
315
|
+
last_safe = 0
|
|
316
|
+
for line in region.splitlines(keepends=True):
|
|
317
|
+
stripped = line.strip()
|
|
318
|
+
if stripped.startswith("```"):
|
|
319
|
+
in_fence = not in_fence
|
|
320
|
+
pos += len(line)
|
|
321
|
+
if not in_fence and stripped == "":
|
|
322
|
+
last_safe = pos
|
|
323
|
+
return last_safe
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class ImageCell(Cell):
|
|
327
|
+
"""An inline image.
|
|
328
|
+
|
|
329
|
+
Renders via the terminal's graphics protocol (kitty / iTerm2) when available, else a
|
|
330
|
+
half-block Unicode fallback (which is just selectable text). Accepts a path, raw bytes,
|
|
331
|
+
or a PIL image. Pillow is required (an install extra); the import is lazy so importing
|
|
332
|
+
xli never needs it.
|
|
333
|
+
|
|
334
|
+
The engine's commit printer calls :meth:`raw_emit` first; if it returns an escape
|
|
335
|
+
string (graphics protocol), that's printed as-is, otherwise the half-block
|
|
336
|
+
:meth:`renderable` lines are printed.
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
def __init__(self, source: Any, *, width_cols: int = 48) -> None:
|
|
340
|
+
super().__init__()
|
|
341
|
+
self.source, self.width_cols = source, width_cols
|
|
342
|
+
|
|
343
|
+
def _load(self): # -> PIL.Image (RGB) or None if Pillow is missing
|
|
344
|
+
try:
|
|
345
|
+
from PIL import Image # lazy
|
|
346
|
+
except ImportError:
|
|
347
|
+
return None
|
|
348
|
+
src = self.source
|
|
349
|
+
if hasattr(src, "convert"): # already a PIL image
|
|
350
|
+
img = src
|
|
351
|
+
elif isinstance(src, (bytes, bytearray)):
|
|
352
|
+
import io as _io
|
|
353
|
+
img = Image.open(_io.BytesIO(src))
|
|
354
|
+
else:
|
|
355
|
+
img = Image.open(src) # path-like
|
|
356
|
+
return img.convert("RGB")
|
|
357
|
+
|
|
358
|
+
def raw_emit(self, term_cols: int) -> str | None:
|
|
359
|
+
"""Graphics-protocol escape for this image, or None to use the half-block path."""
|
|
360
|
+
from . import images
|
|
361
|
+
|
|
362
|
+
img = self._load()
|
|
363
|
+
if img is None or images.protocol() == "halfblock":
|
|
364
|
+
return None
|
|
365
|
+
cols = max(1, min(self.width_cols, term_cols - 1))
|
|
366
|
+
return images.graphics_escape(img, cols)
|
|
367
|
+
|
|
368
|
+
def renderable(self, theme: Theme) -> RenderableType:
|
|
369
|
+
img = self._load()
|
|
370
|
+
if img is None: # Pillow not installed
|
|
371
|
+
return Text("⚠ image — install Pillow: pip install 'xli[images]'",
|
|
372
|
+
style=theme.warning_color)
|
|
373
|
+
cols = self.width_cols
|
|
374
|
+
w, h = img.size
|
|
375
|
+
rows_px = max(2, round(h * cols / w))
|
|
376
|
+
if rows_px % 2:
|
|
377
|
+
rows_px += 1
|
|
378
|
+
img = img.resize((cols, rows_px))
|
|
379
|
+
px = img.load()
|
|
380
|
+
lines = []
|
|
381
|
+
for y in range(0, rows_px, 2):
|
|
382
|
+
t = Text(no_wrap=True)
|
|
383
|
+
for x in range(cols):
|
|
384
|
+
r1, g1, b1 = px[x, y]
|
|
385
|
+
r2, g2, b2 = px[x, y + 1]
|
|
386
|
+
t.append("▀", style=Style(color=f"#{r1:02x}{g1:02x}{b1:02x}",
|
|
387
|
+
bgcolor=f"#{r2:02x}{g2:02x}{b2:02x}"))
|
|
388
|
+
lines.append(t)
|
|
389
|
+
return Group(*lines)
|