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/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
|
+
)
|
xli/render/reasoning.py
ADDED
|
@@ -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
|