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/images.py ADDED
@@ -0,0 +1,90 @@
1
+ """Terminal image protocols — kitty / iTerm2 escapes, with a half-block fallback.
2
+
3
+ Why this is simpler here than in a full-screen TUI: xli commits images to the normal
4
+ scrollback (printed once), so we don't re-emit them every frame or juggle reserved rows
5
+ in a redraw loop — we print the escape once and the terminal scrolls it like any output.
6
+
7
+ Protocol detection is **env-only** (no terminal queries) so it can't interfere with the
8
+ running prompt_toolkit app's input loop. Override with ``XLI_IMAGE_PROTOCOL`` =
9
+ ``kitty`` | ``iterm`` | ``halfblock``. Terminals without a graphics protocol (e.g. Windows
10
+ Terminal) use the half-block renderer in :class:`xli.cells.ImageCell`, which is just text.
11
+
12
+ kitty/iTerm output is best-effort and verified on capable terminals; the half-block path
13
+ is the safe default everywhere.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import io
20
+ import os
21
+
22
+ # Approximate character-cell aspect (height / width). Used to pick a row count that
23
+ # keeps the image from looking stretched.
24
+ _CELL_ASPECT = 2.0
25
+ _KITTY_CHUNK = 4096
26
+
27
+ _protocol: str | None = None
28
+
29
+
30
+ def detect_protocol() -> str:
31
+ """Best-effort env-based detection: 'kitty' | 'iterm' | 'halfblock'."""
32
+ term = os.environ.get("TERM", "")
33
+ if os.environ.get("KITTY_WINDOW_ID") or term == "xterm-kitty" or "ghostty" in term:
34
+ return "kitty"
35
+ if os.environ.get("TERM_PROGRAM", "") in ("iTerm.app", "WezTerm"):
36
+ return "iterm"
37
+ return "halfblock"
38
+
39
+
40
+ def protocol() -> str:
41
+ """The active protocol (cached). ``XLI_IMAGE_PROTOCOL`` overrides detection."""
42
+ global _protocol
43
+ if _protocol is None:
44
+ _protocol = os.environ.get("XLI_IMAGE_PROTOCOL") or detect_protocol()
45
+ return _protocol
46
+
47
+
48
+ def target_rows(width_px: int, height_px: int, cols: int) -> int:
49
+ return max(1, round(cols * (height_px / max(1, width_px)) / _CELL_ASPECT))
50
+
51
+
52
+ def _png_b64(img) -> str:
53
+ buf = io.BytesIO()
54
+ img.save(buf, format="PNG")
55
+ return base64.b64encode(buf.getvalue()).decode("ascii")
56
+
57
+
58
+ def iterm_escape(img, cols: int, rows: int) -> str:
59
+ """iTerm2 inline image (OSC 1337). The image occupies the given cell box and the
60
+ terminal advances the cursor past it."""
61
+ data = _png_b64(img)
62
+ return (f"\033]1337;File=inline=1;width={cols};height={rows};"
63
+ f"preserveAspectRatio=1:{data}\a")
64
+
65
+
66
+ def kitty_escape(img, cols: int, rows: int) -> str:
67
+ """kitty graphics protocol: transmit-and-display a PNG (f=100), scaled into a
68
+ ``cols``×``rows`` cell area, chunked at 4096 bytes per the spec."""
69
+ data = _png_b64(img)
70
+ chunks = [data[i:i + _KITTY_CHUNK] for i in range(0, len(data), _KITTY_CHUNK)] or [""]
71
+ out = []
72
+ for i, chunk in enumerate(chunks):
73
+ more = 1 if i < len(chunks) - 1 else 0
74
+ if i == 0:
75
+ ctrl = f"a=T,f=100,c={cols},r={rows},m={more}"
76
+ else:
77
+ ctrl = f"m={more}"
78
+ out.append(f"\033_G{ctrl};{chunk}\033\\")
79
+ return "".join(out)
80
+
81
+
82
+ def graphics_escape(img, cols: int) -> str | None:
83
+ """Return the escape string for the active graphics protocol, or None for half-block."""
84
+ proto = protocol()
85
+ rows = target_rows(img.size[0], img.size[1], cols)
86
+ if proto == "iterm":
87
+ return iterm_escape(img, cols, rows)
88
+ if proto == "kitty":
89
+ return kitty_escape(img, cols, rows)
90
+ return None
xli/pets.py ADDED
@@ -0,0 +1,27 @@
1
+ """Ambient pets — a tiny animated companion in the status line's bottom-right.
2
+
3
+ Opt-in (``xli.UI(pet="cat")``). Each pet is a list of one-line frames the engine cycles
4
+ slowly; the animation is purely decorative and respects the light aesthetic (muted, no
5
+ chrome). Pass a custom list of frames for your own creature.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+
12
+ PETS: dict[str, list[str]] = {
13
+ # mostly-open frames with an occasional blink/wiggle so it idles gently
14
+ "cat": ["(=^・ω・^=)", "(=^・ω・^=)", "(=^・ω・^=)", "(=˘ω˘=)"],
15
+ "dog": ["( •ᴥ• )", "( •ᴥ• )", "( •ᴥ• )", "( ◕ᴥ◕ )"],
16
+ "fox": ["(^≖ᆺ≖^)", "(^≖ᆺ≖^)", "(^-ᆺ-^)"],
17
+ "owl": ["{ʘᴥʘ}", "{ʘᴥʘ}", "{-ᴥ-}"],
18
+ }
19
+
20
+
21
+ def frames(pet: str | Sequence[str] | None) -> list[str] | None:
22
+ """Resolve a pet name or explicit frame list to frames (None disables)."""
23
+ if pet is None:
24
+ return None
25
+ if isinstance(pet, str):
26
+ return PETS.get(pet, PETS["cat"])
27
+ return list(pet)
xli/render/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Renderers — pure functions from data → Rich renderables.
2
+
3
+ Each module returns a ``rich.console.RenderableType``. The :class:`xli.UI`
4
+ prints these into the transcript. They're pure so they can be tested
5
+ without a terminal and reused outside the UI loop.
6
+ """
7
+
8
+ from .diff import render_diff
9
+ from .message import render_message, render_streaming_message
10
+ from .plan import render_plan
11
+ from .reasoning import render_reasoning
12
+ from .tool import render_tool
13
+
14
+ __all__ = [
15
+ "render_diff",
16
+ "render_message",
17
+ "render_plan",
18
+ "render_reasoning",
19
+ "render_streaming_message",
20
+ "render_tool",
21
+ ]
xli/render/diff.py ADDED
@@ -0,0 +1,37 @@
1
+ """Render unified diffs with sane colors and an optional path header."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Group, RenderableType
6
+ from rich.text import Text
7
+
8
+ from ..theme import Theme
9
+
10
+
11
+ def render_diff(
12
+ diff: str,
13
+ *,
14
+ path: str | None = None,
15
+ theme: Theme,
16
+ ) -> RenderableType:
17
+ """Colorize a unified-diff string. Path header is muted+bold above the diff."""
18
+
19
+ body = Text(no_wrap=False)
20
+ for i, line in enumerate(diff.splitlines()):
21
+ if i > 0:
22
+ body.append("\n")
23
+ if line.startswith("+++") or line.startswith("---"):
24
+ body.append(line, style="bold")
25
+ elif line.startswith("@@"):
26
+ body.append(line, style=theme.diff_hunk_color)
27
+ elif line.startswith("+"):
28
+ body.append(line, style=theme.diff_add_color)
29
+ elif line.startswith("-"):
30
+ body.append(line, style=theme.diff_del_color)
31
+ else:
32
+ body.append(line)
33
+
34
+ if path is None:
35
+ return body
36
+ header = Text(f"diff {path}", style=f"bold {theme.muted_color}")
37
+ return Group(header, body)
xli/render/message.py ADDED
@@ -0,0 +1,115 @@
1
+ """Render user / assistant / system messages.
2
+
3
+ Each message is a small Rich renderable group: an optional role label
4
+ followed by the body. The body is markdown for assistant messages, plain
5
+ for user/system. Borders / left-rails are governed by the theme.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Literal
11
+
12
+ from rich import box as _box
13
+ from rich.console import Group, RenderableType
14
+ from rich.markdown import Markdown
15
+ from rich.padding import Padding
16
+ from rich.panel import Panel
17
+ from rich.text import Text
18
+
19
+ from ..theme import Theme
20
+
21
+ Role = Literal["user", "assistant", "system"]
22
+
23
+
24
+ def _box_for(name: str):
25
+ """Map a theme box name (e.g. 'rounded') to a rich Box, defaulting to ROUNDED."""
26
+ return getattr(_box, name.upper(), _box.ROUNDED)
27
+
28
+
29
+ def render_message(
30
+ text: str,
31
+ *,
32
+ role: Role,
33
+ theme: Theme,
34
+ markdown: bool | None = None,
35
+ label: bool = True,
36
+ ) -> RenderableType:
37
+ """Static message render (the streaming case sees ``render_streaming_message``).
38
+
39
+ ``label=False`` suppresses the role label — used for streamed continuation chunks
40
+ that commit under an already-labelled first chunk (so the label isn't repeated).
41
+ """
42
+
43
+ # System messages are compact by design: ``· text`` on as few lines as
44
+ # possible. They're meta-info, not part of the conversation flow.
45
+ if role == "system":
46
+ return _render_system(text, theme=theme)
47
+
48
+ use_md = markdown if markdown is not None else (role == "assistant")
49
+ body: RenderableType
50
+ if use_md and text.strip():
51
+ body = Markdown(
52
+ text,
53
+ code_theme=theme.code_theme,
54
+ inline_code_theme=theme.code_theme,
55
+ )
56
+ else:
57
+ body = Text(text or "", no_wrap=False)
58
+
59
+ if theme.use_borders:
60
+ return Panel(
61
+ body,
62
+ title=_role_label_text(role, theme) if label else None,
63
+ title_align="left",
64
+ border_style=_role_color(role, theme),
65
+ box=_box_for(theme.panel_border),
66
+ )
67
+
68
+ parts: list[RenderableType] = []
69
+ if label and theme.show_role_labels:
70
+ parts.append(_role_label_text(role, theme))
71
+ parts.append(Padding(body, (0, 0, 0, 2)))
72
+ return Group(*parts)
73
+
74
+
75
+ def _render_system(text: str, *, theme: Theme) -> RenderableType:
76
+ """``· first line``, additional lines indented to align with the body."""
77
+
78
+ lines = (text or "").splitlines() or [""]
79
+ out = Text()
80
+ for i, line in enumerate(lines):
81
+ if i > 0:
82
+ out.append("\n")
83
+ if i == 0:
84
+ out.append("· ", style=theme.muted_color)
85
+ else:
86
+ out.append(" ")
87
+ out.append(line, style=theme.muted_color)
88
+ return out
89
+
90
+
91
+ def render_streaming_message(
92
+ text: str, *, role: Role, theme: Theme, markdown: bool | None = None
93
+ ) -> RenderableType:
94
+ """Renderable suitable for repeated update inside Rich's ``Live`` context.
95
+
96
+ Same shape as :func:`render_message` but tolerant of partial markdown
97
+ (we don't try to "complete" it — Rich handles partial blocks fine).
98
+ """
99
+ return render_message(text or " ", role=role, theme=theme, markdown=markdown)
100
+
101
+
102
+ def _role_label_text(role: Role, theme: Theme) -> Text:
103
+ if role == "user":
104
+ return Text(theme.user_label, style=f"bold {theme.user_color}")
105
+ if role == "assistant":
106
+ return Text(theme.assistant_label, style=f"bold {theme.assistant_color}")
107
+ return Text(theme.system_label, style=f"bold {theme.system_color}")
108
+
109
+
110
+ def _role_color(role: Role, theme: Theme) -> str:
111
+ if role == "user":
112
+ return theme.user_color
113
+ if role == "assistant":
114
+ return theme.assistant_color
115
+ return theme.system_color
xli/render/plan.py ADDED
@@ -0,0 +1,70 @@
1
+ """Render a plan: list of (title, status) steps as a checklist.
2
+
3
+ Status values: ``"pending"`` (default), ``"in_progress"``, ``"completed"``.
4
+ Anything else is treated as ``"pending"`` (forward-compat).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterable
10
+ from typing import Any
11
+
12
+ from rich.console import RenderableType
13
+ from rich.text import Text
14
+
15
+ from ..theme import Theme
16
+
17
+
18
+ def render_plan(
19
+ steps: Iterable[Any],
20
+ *,
21
+ theme: Theme,
22
+ title: str | None = None,
23
+ ) -> RenderableType:
24
+ out = Text()
25
+ if title:
26
+ out.append(title, style=f"bold {theme.plan_color}")
27
+ out.append("\n")
28
+ rows = list(_iter_steps(steps))
29
+ for i, (step_title, status, notes) in enumerate(rows):
30
+ glyph = _glyph(status, theme)
31
+ out.append(f" {glyph} ", style=theme.plan_color)
32
+ out.append(step_title)
33
+ if notes:
34
+ out.append(f" ({notes})", style=theme.muted_color)
35
+ if i != len(rows) - 1:
36
+ out.append("\n")
37
+ return out
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # helpers
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ def _glyph(status: str, theme: Theme) -> str:
46
+ if status == "completed":
47
+ return theme.plan_completed_glyph
48
+ if status == "in_progress":
49
+ return theme.plan_in_progress_glyph
50
+ return theme.plan_pending_glyph
51
+
52
+
53
+ def _iter_steps(steps: Iterable[Any]) -> Iterable[tuple[str, str, str | None]]:
54
+ """Accept any of: dicts, dataclasses, (title, status[, notes]) tuples, bare strings."""
55
+ for s in steps:
56
+ if isinstance(s, dict):
57
+ yield (str(s.get("title", "")), str(s.get("status", "pending")), s.get("notes"))
58
+ elif isinstance(s, str):
59
+ yield (s, "pending", None)
60
+ elif isinstance(s, tuple):
61
+ if len(s) == 2:
62
+ yield (str(s[0]), str(s[1]), None)
63
+ else:
64
+ yield (str(s[0]), str(s[1]), str(s[2]) if s[2] else None)
65
+ else:
66
+ yield (
67
+ str(getattr(s, "title", s)),
68
+ str(getattr(s, "status", "pending")),
69
+ getattr(s, "notes", None),
70
+ )
@@ -0,0 +1,20 @@
1
+ """Render reasoning summaries — muted, lightly-railed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import RenderableType
6
+ from rich.text import Text
7
+
8
+ from ..theme import Theme
9
+
10
+
11
+ def render_reasoning(summary: str, *, theme: Theme) -> RenderableType:
12
+ text = Text()
13
+ rail = theme.reasoning_glyph
14
+ style = theme.reasoning_color
15
+ for i, line in enumerate(summary.strip().splitlines() or [""]):
16
+ if i > 0:
17
+ text.append("\n")
18
+ text.append(f"{rail} ", style=style)
19
+ text.append(line, style=style)
20
+ return text
xli/render/tool.py ADDED
@@ -0,0 +1,128 @@
1
+ """Render tool calls.
2
+
3
+ Each tool call is a compact card:
4
+
5
+ ▸ shell ls -la
6
+ total 48
7
+ drwxr-xr-x 8 ...
8
+
9
+ Title line uses the tool glyph + name + a short summary; the output is
10
+ indented. When output is missing or empty, just the title shows.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from typing import Any
17
+
18
+ from rich.console import Group, RenderableType
19
+ from rich.padding import Padding
20
+ from rich.text import Text
21
+
22
+ from ..theme import Theme
23
+
24
+ _MAX_TITLE_DETAIL = 100
25
+ _MAX_OUTPUT_INLINE = 8000
26
+
27
+
28
+ def render_tool(
29
+ name: str,
30
+ *,
31
+ args: dict[str, Any] | None = None,
32
+ output: Any = None,
33
+ error: str | None = None,
34
+ status: str | None = None,
35
+ theme: Theme,
36
+ ) -> RenderableType:
37
+ args = args or {}
38
+ title = _title_for(name, args, theme=theme, errored=error is not None, status=status)
39
+ if output is None and error is None:
40
+ return title
41
+ body_text = _body_for(output, error)
42
+ if not body_text:
43
+ return title
44
+ body = Padding(
45
+ Text(body_text, style=theme.muted_color, no_wrap=False),
46
+ (0, 0, 0, theme.tool_output_indent),
47
+ )
48
+ return Group(title, body)
49
+
50
+
51
+ def _title_for(
52
+ name: str, args: dict[str, Any], *, theme: Theme, errored: bool, status: str | None = None
53
+ ) -> Text:
54
+ # Status drives the gutter glyph + accent so a card reads at a glance as it
55
+ # moves running -> done. When no status is given we keep the plain tool glyph
56
+ # (back-compat with one-shot ``ui.tool(...)`` cards).
57
+ if errored or status == "error":
58
+ color, glyph = theme.error_color, theme.tool_error_glyph
59
+ elif status == "cancelled":
60
+ color, glyph = theme.error_color, theme.tool_cancelled_glyph
61
+ elif status == "done":
62
+ color, glyph = theme.success_color, theme.tool_done_glyph
63
+ else: # running / None
64
+ color, glyph = theme.tool_color, theme.tool_glyph
65
+ summary = _summary_for(name, args)
66
+ title = Text()
67
+ title.append(f"{glyph} ", style=color)
68
+ title.append(name, style=f"bold {color}")
69
+ if summary:
70
+ title.append(" ")
71
+ title.append(summary, style=theme.muted_color)
72
+ return title
73
+
74
+
75
+ def _summary_for(name: str, args: dict[str, Any]) -> str:
76
+ if name == "shell":
77
+ return _truncate(" ".join(args.get("command", []) or args.get("argv", [])), _MAX_TITLE_DETAIL)
78
+ if name == "apply_patch":
79
+ patch = args.get("patch", "")
80
+ n = (
81
+ patch.count("*** Add File:")
82
+ + patch.count("*** Update File:")
83
+ + patch.count("*** Delete File:")
84
+ ) or 1
85
+ return f"({n} change{'s' if n != 1 else ''})"
86
+ if name == "view_image":
87
+ return str(args.get("path", ""))
88
+ if name == "save_artifact":
89
+ return str(args.get("path", ""))
90
+ if name == "read_artifact":
91
+ return str(args.get("path", ""))
92
+ if name == "web_search":
93
+ return str(args.get("query", ""))
94
+ if name == "update_plan":
95
+ steps = args.get("steps") or []
96
+ return f"({len(steps)} step{'s' if len(steps) != 1 else ''})"
97
+ if name == "remember":
98
+ return _truncate(str(args.get("content", "")), _MAX_TITLE_DETAIL)
99
+ if name == "task":
100
+ sub = args.get("subagent_type", "")
101
+ desc = args.get("description", "")
102
+ return _truncate(f"[{sub}] {desc}".strip(), _MAX_TITLE_DETAIL) if (sub or desc) else ""
103
+ # generic fallback
104
+ if args:
105
+ return _truncate(json.dumps(args, default=str, separators=(",", ":")), _MAX_TITLE_DETAIL)
106
+ return ""
107
+
108
+
109
+ def _body_for(output: Any, error: str | None) -> str:
110
+ if error:
111
+ return f"error: {error}"
112
+ if output is None:
113
+ return ""
114
+ if isinstance(output, str):
115
+ return _truncate(output, _MAX_OUTPUT_INLINE)
116
+ if isinstance(output, (bytes, bytearray)):
117
+ return f"<{len(output)} bytes>"
118
+ try:
119
+ return _truncate(json.dumps(output, default=str, indent=2), _MAX_OUTPUT_INLINE)
120
+ except Exception: # pragma: no cover
121
+ return _truncate(repr(output), _MAX_OUTPUT_INLINE)
122
+
123
+
124
+ def _truncate(text: str, limit: int) -> str:
125
+ text = str(text)
126
+ if len(text) <= limit:
127
+ return text
128
+ return text[: limit - 1] + "…"
xli/render_bridge.py ADDED
@@ -0,0 +1,36 @@
1
+ """The render bridge — Rich renderables → ANSI lines for the prompt_toolkit engine.
2
+
3
+ The engine draws its live region (and commits to scrollback) as plain ANSI text.
4
+ Rich does all the actual rendering — markdown, syntax highlighting, tables, diffs,
5
+ half-block images — and this module turns a renderable into a list of ANSI strings
6
+ at a given width. prompt_toolkit then displays each line via ``ANSI(...)``.
7
+
8
+ Kept tiny and pure so it's trivially testable and cache-friendly.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import io
14
+
15
+ from rich.console import Console, RenderableType
16
+
17
+
18
+ def render_to_ansi(renderable: RenderableType, width: int) -> list[str]:
19
+ """Render ``renderable`` to a list of ANSI lines at ``width`` columns.
20
+
21
+ Truecolor is forced (degrade is the terminal's job via its own palette); a
22
+ trailing empty line from Rich's newline is stripped so callers control spacing.
23
+ """
24
+ buf = io.StringIO()
25
+ Console(
26
+ file=buf,
27
+ width=max(1, width),
28
+ force_terminal=True,
29
+ color_system="truecolor",
30
+ highlight=False,
31
+ soft_wrap=False,
32
+ ).print(renderable, end="")
33
+ lines = buf.getvalue().split("\n")
34
+ if lines and lines[-1] == "":
35
+ lines.pop()
36
+ return lines