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