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/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)