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/slash.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Slash command registry + prompt_toolkit completer.
|
|
2
|
+
|
|
3
|
+
User-facing API:
|
|
4
|
+
|
|
5
|
+
@ui.command("model", description="switch model", aliases=["m"])
|
|
6
|
+
async def cmd_model(ui, args): ...
|
|
7
|
+
|
|
8
|
+
Internally we keep one registry per :class:`UI` instance.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
|
18
|
+
from prompt_toolkit.document import Document
|
|
19
|
+
from prompt_toolkit.lexers import Lexer
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .ui import UI
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
Handler = Callable[["UI", str], Awaitable[None]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class SlashCommand:
|
|
30
|
+
"""One registered slash command."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
handler: Handler
|
|
34
|
+
description: str = ""
|
|
35
|
+
aliases: tuple[str, ...] = ()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SlashRegistry:
|
|
39
|
+
"""Per-UI command registry. Supports aliases + prefix lookup."""
|
|
40
|
+
|
|
41
|
+
def __init__(self) -> None:
|
|
42
|
+
self._by_name: dict[str, SlashCommand] = {}
|
|
43
|
+
|
|
44
|
+
def register(self, cmd: SlashCommand) -> None:
|
|
45
|
+
self._by_name[cmd.name] = cmd
|
|
46
|
+
for alias in cmd.aliases:
|
|
47
|
+
self._by_name[alias] = cmd
|
|
48
|
+
|
|
49
|
+
def unregister(self, name: str) -> None:
|
|
50
|
+
cmd = self._by_name.pop(name, None)
|
|
51
|
+
if cmd is None:
|
|
52
|
+
return
|
|
53
|
+
for alias in cmd.aliases:
|
|
54
|
+
self._by_name.pop(alias, None)
|
|
55
|
+
|
|
56
|
+
def get(self, name: str) -> SlashCommand | None:
|
|
57
|
+
return self._by_name.get(name)
|
|
58
|
+
|
|
59
|
+
def all(self) -> list[SlashCommand]:
|
|
60
|
+
"""Each canonical command once (aliases deduped)."""
|
|
61
|
+
seen: set[str] = set()
|
|
62
|
+
out: list[SlashCommand] = []
|
|
63
|
+
for cmd in self._by_name.values():
|
|
64
|
+
if cmd.name in seen:
|
|
65
|
+
continue
|
|
66
|
+
seen.add(cmd.name)
|
|
67
|
+
out.append(cmd)
|
|
68
|
+
out.sort(key=lambda c: c.name)
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
def match(self, prefix: str, *, limit: int = 12) -> list[SlashCommand]:
|
|
72
|
+
prefix = prefix.lstrip("/").lower()
|
|
73
|
+
if not prefix:
|
|
74
|
+
return self.all()[:limit]
|
|
75
|
+
out: list[SlashCommand] = []
|
|
76
|
+
seen: set[str] = set()
|
|
77
|
+
# Names that start with the prefix first.
|
|
78
|
+
for cmd in self.all():
|
|
79
|
+
if cmd.name.startswith(prefix):
|
|
80
|
+
out.append(cmd)
|
|
81
|
+
seen.add(cmd.name)
|
|
82
|
+
# Then aliases that start with prefix (but commands not yet listed).
|
|
83
|
+
for name, cmd in self._by_name.items():
|
|
84
|
+
if cmd.name in seen:
|
|
85
|
+
continue
|
|
86
|
+
if name.startswith(prefix):
|
|
87
|
+
out.append(cmd)
|
|
88
|
+
seen.add(cmd.name)
|
|
89
|
+
return out[:limit]
|
|
90
|
+
|
|
91
|
+
def parse(self, line: str) -> tuple[SlashCommand | None, str]:
|
|
92
|
+
"""Split ``/<name> <args>`` → (cmd-or-None, args)."""
|
|
93
|
+
if not line.startswith("/"):
|
|
94
|
+
return None, line
|
|
95
|
+
body = line[1:].lstrip()
|
|
96
|
+
name, _, args = body.partition(" ")
|
|
97
|
+
return self.get(name.lower()), args
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class SlashCompleter(Completer):
|
|
101
|
+
"""prompt_toolkit completer for slash commands.
|
|
102
|
+
|
|
103
|
+
Active only when the buffer starts with ``/`` and contains no spaces yet.
|
|
104
|
+
Yields each matching command name as a completion.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, registry: SlashRegistry) -> None:
|
|
108
|
+
self.registry = registry
|
|
109
|
+
|
|
110
|
+
def get_completions(self, document: Document, complete_event: CompleteEvent):
|
|
111
|
+
text = document.text_before_cursor
|
|
112
|
+
if not text.startswith("/"):
|
|
113
|
+
return
|
|
114
|
+
if " " in text:
|
|
115
|
+
return
|
|
116
|
+
prefix = text[1:]
|
|
117
|
+
for cmd in self.registry.match("/" + prefix):
|
|
118
|
+
yield Completion(
|
|
119
|
+
cmd.name,
|
|
120
|
+
start_position=-len(prefix),
|
|
121
|
+
display=f"/{cmd.name}",
|
|
122
|
+
display_meta=cmd.description,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class SlashLexer(Lexer):
|
|
127
|
+
"""Highlight a *recognized* ``/command`` in the composer.
|
|
128
|
+
|
|
129
|
+
The moment the typed first word is an exact registered command (or alias), the
|
|
130
|
+
``/name`` token is styled with ``class:slash`` — a recognition cue. While it's only
|
|
131
|
+
a partial match (``/imag``) it stays default-colored, so the color flips exactly at
|
|
132
|
+
the full-match boundary and reverts on backspace. Arguments after the command and
|
|
133
|
+
any non-command text render normally.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(self, registry: SlashRegistry, style: str = "class:slash") -> None:
|
|
137
|
+
self.registry = registry
|
|
138
|
+
self.style = style
|
|
139
|
+
|
|
140
|
+
def lex_document(self, document: Document):
|
|
141
|
+
lines = document.lines
|
|
142
|
+
|
|
143
|
+
def get_line(lineno: int):
|
|
144
|
+
text = lines[lineno] if lineno < len(lines) else ""
|
|
145
|
+
if lineno == 0 and text.startswith("/"):
|
|
146
|
+
name, sep, rest = text[1:].partition(" ")
|
|
147
|
+
if name and self.registry.get(name.lower()) is not None:
|
|
148
|
+
frags = [(self.style, "/" + name)]
|
|
149
|
+
if sep:
|
|
150
|
+
frags.append(("", sep + rest))
|
|
151
|
+
return frags
|
|
152
|
+
return [("", text)]
|
|
153
|
+
|
|
154
|
+
return get_line
|
xli/status.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Status bar — named fields rendered into prompt_toolkit's bottom toolbar.
|
|
2
|
+
|
|
3
|
+
Usage from a UI handler::
|
|
4
|
+
|
|
5
|
+
ui.status.set(model="gpt-5-codex", tokens="3.2k/400k")
|
|
6
|
+
|
|
7
|
+
Only fields listed in :class:`UI`'s ``status_fields`` are rendered; that
|
|
8
|
+
keeps the bar tidy and the order stable.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
|
|
15
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
16
|
+
|
|
17
|
+
from .theme import Theme
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StatusBar:
|
|
21
|
+
"""Tracks named-field state. Used by the Composer's bottom toolbar callable."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
fields: Iterable[str],
|
|
27
|
+
theme: Theme,
|
|
28
|
+
) -> None:
|
|
29
|
+
self._order = tuple(fields)
|
|
30
|
+
self._values: dict[str, str] = {f: "" for f in self._order}
|
|
31
|
+
self._theme = theme
|
|
32
|
+
|
|
33
|
+
def set(self, **kwargs: object) -> None:
|
|
34
|
+
for key, value in kwargs.items():
|
|
35
|
+
if key not in self._values:
|
|
36
|
+
# Unknown fields are silently ignored — UIs evolve; better
|
|
37
|
+
# than throwing in the middle of a turn.
|
|
38
|
+
continue
|
|
39
|
+
self._values[key] = "" if value is None else str(value)
|
|
40
|
+
|
|
41
|
+
def get(self, name: str) -> str:
|
|
42
|
+
return self._values.get(name, "")
|
|
43
|
+
|
|
44
|
+
def render(self) -> FormattedText:
|
|
45
|
+
"""Render as prompt_toolkit ``FormattedText`` for the bottom toolbar."""
|
|
46
|
+
parts: list[tuple[str, str]] = []
|
|
47
|
+
first = True
|
|
48
|
+
for name in self._order:
|
|
49
|
+
value = self._values.get(name, "")
|
|
50
|
+
if not value:
|
|
51
|
+
continue
|
|
52
|
+
if not first:
|
|
53
|
+
parts.append(("class:status-sep", self._theme.status_separator))
|
|
54
|
+
parts.append(("class:status", value))
|
|
55
|
+
first = False
|
|
56
|
+
return FormattedText(parts)
|
|
57
|
+
|
|
58
|
+
def is_empty(self) -> bool:
|
|
59
|
+
return not any(self._values.values())
|
xli/theme.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Visual theme — colors, glyphs, role styling.
|
|
2
|
+
|
|
3
|
+
Themes are dataclasses, not classes you subclass. To customize, instantiate
|
|
4
|
+
:class:`Theme` with the fields you want to override; everything else uses
|
|
5
|
+
the codex-inspired defaults.
|
|
6
|
+
|
|
7
|
+
Three presets are bundled:
|
|
8
|
+
|
|
9
|
+
* ``"codex"`` (default) — flowing log, no borders, glyph-driven gutters
|
|
10
|
+
* ``"minimal"`` — even more austere: no glyphs, just indented text
|
|
11
|
+
* ``"boxed"`` — for users who want Rich-style rounded panels
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, replace
|
|
17
|
+
from typing import Literal
|
|
18
|
+
|
|
19
|
+
ThemeName = Literal["codex", "minimal", "boxed"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Theme:
|
|
24
|
+
"""Visual configuration for the UI. All fields have sane defaults."""
|
|
25
|
+
|
|
26
|
+
# --- role labels ---
|
|
27
|
+
user_label: str = "you"
|
|
28
|
+
assistant_label: str = "assistant"
|
|
29
|
+
system_label: str = "system"
|
|
30
|
+
show_role_labels: bool = True
|
|
31
|
+
|
|
32
|
+
user_color: str = "cyan"
|
|
33
|
+
assistant_color: str = "green"
|
|
34
|
+
system_color: str = "grey50"
|
|
35
|
+
|
|
36
|
+
# --- structural glyphs ---
|
|
37
|
+
tool_glyph: str = "▸"
|
|
38
|
+
tool_done_glyph: str = "✓"
|
|
39
|
+
tool_error_glyph: str = "✗"
|
|
40
|
+
tool_cancelled_glyph: str = "⦻"
|
|
41
|
+
tool_color: str = "blue"
|
|
42
|
+
tool_output_indent: int = 2
|
|
43
|
+
|
|
44
|
+
reasoning_glyph: str = "│"
|
|
45
|
+
reasoning_color: str = "grey50"
|
|
46
|
+
|
|
47
|
+
plan_pending_glyph: str = "☐"
|
|
48
|
+
plan_in_progress_glyph: str = "▸"
|
|
49
|
+
plan_completed_glyph: str = "☑"
|
|
50
|
+
plan_color: str = "magenta"
|
|
51
|
+
|
|
52
|
+
approval_glyph: str = "⚠"
|
|
53
|
+
approval_color: str = "yellow"
|
|
54
|
+
|
|
55
|
+
error_color: str = "red"
|
|
56
|
+
warning_color: str = "yellow"
|
|
57
|
+
success_color: str = "green"
|
|
58
|
+
muted_color: str = "grey50"
|
|
59
|
+
|
|
60
|
+
# --- diff ---
|
|
61
|
+
diff_add_color: str = "green"
|
|
62
|
+
diff_del_color: str = "red"
|
|
63
|
+
diff_hunk_color: str = "magenta"
|
|
64
|
+
|
|
65
|
+
# --- code blocks (passed to rich.Syntax) ---
|
|
66
|
+
code_theme: str = "monokai"
|
|
67
|
+
code_word_wrap: bool = True
|
|
68
|
+
code_background: bool = False
|
|
69
|
+
|
|
70
|
+
# --- composer ---
|
|
71
|
+
# The prompt is a glyph in a soft accent color — NOT a filled block. We avoid
|
|
72
|
+
# solid backgrounds for chrome (see docs/theme.md): separation comes from font
|
|
73
|
+
# color + a thin rule, so the UI reads "light" and terminal-native.
|
|
74
|
+
prompt_glyph: str = ">"
|
|
75
|
+
prompt_color: str = "bold cyan" # foreground of the glyph
|
|
76
|
+
prompt_bg: str = "" # no background block
|
|
77
|
+
prompt_text_color: str = "default" # typed text uses the terminal's own fg
|
|
78
|
+
command_color: str = "bold cyan" # a recognized /command in the composer
|
|
79
|
+
multiline_continuation: str = " "
|
|
80
|
+
|
|
81
|
+
# --- borders / spacing ---
|
|
82
|
+
use_borders: bool = False
|
|
83
|
+
panel_border: str = "rounded" # if use_borders is True
|
|
84
|
+
item_spacing: int = 1 # blank lines between transcript items
|
|
85
|
+
|
|
86
|
+
# --- status bar ---
|
|
87
|
+
status_separator: str = " · "
|
|
88
|
+
status_color: str = "grey50"
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def named(cls, name: ThemeName) -> Theme:
|
|
92
|
+
if name == "codex":
|
|
93
|
+
return CODEX
|
|
94
|
+
if name == "minimal":
|
|
95
|
+
return MINIMAL
|
|
96
|
+
if name == "boxed":
|
|
97
|
+
return BOXED
|
|
98
|
+
raise ValueError(f"unknown theme: {name!r}")
|
|
99
|
+
|
|
100
|
+
def with_overrides(self, **kwargs) -> Theme:
|
|
101
|
+
"""Return a copy with the given fields replaced."""
|
|
102
|
+
return replace(self, **kwargs)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Presets
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
#: The default theme. Aesthetic: codex / aider — minimal, glyph-driven, no boxes.
|
|
111
|
+
CODEX = Theme()
|
|
112
|
+
|
|
113
|
+
#: Even quieter — no glyphs, no colors except where strictly needed.
|
|
114
|
+
MINIMAL = Theme(
|
|
115
|
+
show_role_labels=True,
|
|
116
|
+
user_color="default",
|
|
117
|
+
assistant_color="default",
|
|
118
|
+
tool_glyph=">",
|
|
119
|
+
tool_done_glyph="[ok]",
|
|
120
|
+
tool_error_glyph="[x]",
|
|
121
|
+
tool_cancelled_glyph="[-]",
|
|
122
|
+
tool_color="default",
|
|
123
|
+
reasoning_glyph=" ",
|
|
124
|
+
reasoning_color="default",
|
|
125
|
+
plan_pending_glyph="[ ]",
|
|
126
|
+
plan_in_progress_glyph="[~]",
|
|
127
|
+
plan_completed_glyph="[x]",
|
|
128
|
+
plan_color="default",
|
|
129
|
+
approval_glyph="!",
|
|
130
|
+
approval_color="default",
|
|
131
|
+
prompt_glyph=">",
|
|
132
|
+
prompt_color="default",
|
|
133
|
+
prompt_bg="",
|
|
134
|
+
prompt_text_color="default",
|
|
135
|
+
command_color="bold", # austere: recognition via weight, not color
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
#: For users who want rounded panels around each item.
|
|
139
|
+
BOXED = Theme(
|
|
140
|
+
use_borders=True,
|
|
141
|
+
show_role_labels=True,
|
|
142
|
+
tool_glyph="▸",
|
|
143
|
+
item_spacing=0,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def resolve(theme: Theme | ThemeName | None) -> Theme:
|
|
148
|
+
"""Accept ``Theme`` instance, name string, or None (→ default)."""
|
|
149
|
+
if theme is None:
|
|
150
|
+
return CODEX
|
|
151
|
+
if isinstance(theme, Theme):
|
|
152
|
+
return theme
|
|
153
|
+
return Theme.named(theme)
|