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/engine.py
ADDED
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
"""The runtime engine — inline two-tier transcript on prompt_toolkit.
|
|
2
|
+
|
|
3
|
+
This is the heart of xli v2, validated by the Phase 0 spike. Design:
|
|
4
|
+
|
|
5
|
+
* **Inline, not full-screen.** The app runs under ``patch_stdout`` and renders only a
|
|
6
|
+
small live region at the bottom. Finalized cells are *printed into normal terminal
|
|
7
|
+
scrollback* — so transcript text is natively selectable and scrollable. Only the
|
|
8
|
+
active tail (running tool cards, spinners, the open stream, status, composer) is
|
|
9
|
+
redrawn. (Full-screen breaks selection; that's why we don't use it.)
|
|
10
|
+
* **Two tiers.** ``live`` cells are mutable + animated; on reaching ``final`` they
|
|
11
|
+
commit to scrollback and become immutable.
|
|
12
|
+
* **Concurrent.** The composer stays live while a handler runs as a task. Submissions
|
|
13
|
+
queue (type-ahead). ESC cancels the running turn cooperatively and fires the
|
|
14
|
+
``on_interrupt`` cleanup hook; the session survives.
|
|
15
|
+
|
|
16
|
+
The engine implements :class:`xli.cells.CellSink`. The public :class:`xli.UI` is a thin
|
|
17
|
+
facade over it.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import shutil
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
from collections.abc import Awaitable, Callable, Coroutine
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from prompt_toolkit.application import Application
|
|
30
|
+
from prompt_toolkit.buffer import Buffer
|
|
31
|
+
from prompt_toolkit.filters import Condition
|
|
32
|
+
from prompt_toolkit.formatted_text import ANSI, to_formatted_text
|
|
33
|
+
from prompt_toolkit.history import FileHistory, InMemoryHistory
|
|
34
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
35
|
+
from prompt_toolkit.layout import HSplit, Layout, Window
|
|
36
|
+
from prompt_toolkit.layout.containers import ConditionalContainer
|
|
37
|
+
from prompt_toolkit.layout.controls import BufferControl, UIContent, UIControl
|
|
38
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
39
|
+
from prompt_toolkit.patch_stdout import patch_stdout
|
|
40
|
+
from prompt_toolkit.styles import Style
|
|
41
|
+
from rich.console import Group
|
|
42
|
+
from rich.text import Text
|
|
43
|
+
|
|
44
|
+
from .approval import Decision
|
|
45
|
+
from .cells import (
|
|
46
|
+
ApprovalCell,
|
|
47
|
+
Cell,
|
|
48
|
+
CustomCell,
|
|
49
|
+
MessageCell,
|
|
50
|
+
NoteCell,
|
|
51
|
+
SpinnerCell,
|
|
52
|
+
StreamingCell,
|
|
53
|
+
ToolCell,
|
|
54
|
+
)
|
|
55
|
+
from .render_bridge import render_to_ansi
|
|
56
|
+
from .slash import SlashLexer, SlashRegistry
|
|
57
|
+
from .status import StatusBar
|
|
58
|
+
from .theme import Theme
|
|
59
|
+
|
|
60
|
+
Handler = Callable[[str], Coroutine[Any, Any, None]]
|
|
61
|
+
InterruptHook = Callable[[], Awaitable[None]]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Engine:
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
theme: Theme,
|
|
69
|
+
slash: SlashRegistry,
|
|
70
|
+
status: StatusBar,
|
|
71
|
+
title: str | None = None,
|
|
72
|
+
intro: str | None = None,
|
|
73
|
+
history_file: str | None = None,
|
|
74
|
+
pet: list[str] | None = None,
|
|
75
|
+
notify_after: float | None = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
self.theme = theme
|
|
78
|
+
self._slash = slash
|
|
79
|
+
self._status = status
|
|
80
|
+
self._title = title
|
|
81
|
+
self._intro = intro
|
|
82
|
+
self._history_file = history_file
|
|
83
|
+
self._pet = pet
|
|
84
|
+
self._pet_i = 0
|
|
85
|
+
self._pet_ticks = 0
|
|
86
|
+
self._notify_after = notify_after
|
|
87
|
+
|
|
88
|
+
# scene
|
|
89
|
+
self.live: list[Cell] = []
|
|
90
|
+
self.committed: list[Cell] = []
|
|
91
|
+
|
|
92
|
+
# dispatch / turn state
|
|
93
|
+
self.queue: asyncio.Queue[str] = asyncio.Queue()
|
|
94
|
+
self._pending: list[str] = [] # queued prompts, shown as type-ahead in the live tail
|
|
95
|
+
self._current: asyncio.Task | None = None
|
|
96
|
+
self._handler: Handler | None = None
|
|
97
|
+
self._on_interrupt: InterruptHook | None = None
|
|
98
|
+
self._exit = asyncio.Event()
|
|
99
|
+
|
|
100
|
+
# inline-modal state: an arrow-selectable picker (approve/confirm/pick/wizard)
|
|
101
|
+
# and a one-line capture (input). Both render in the live region.
|
|
102
|
+
self._picker: _Picker | None = None
|
|
103
|
+
self._line: asyncio.Future[str | None] | None = None
|
|
104
|
+
|
|
105
|
+
# completion — our own list rendered below the composer (replaces the pt popup,
|
|
106
|
+
# which used solid-bg chrome + fragile float positioning). Serves /commands and
|
|
107
|
+
# @file mentions.
|
|
108
|
+
self._sugg_index = 0
|
|
109
|
+
self._suggest_dismissed = False
|
|
110
|
+
self._file_cache: list[str] | None = None
|
|
111
|
+
|
|
112
|
+
# wiring filled in at run()
|
|
113
|
+
self._invalidate: Callable[[], None] = lambda: None
|
|
114
|
+
self._print_committed: Callable[[Cell], None] | None = None
|
|
115
|
+
self._app: Application | None = None
|
|
116
|
+
self._buffer: Buffer | None = None
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------- CellSink
|
|
119
|
+
def emit(self, cell: Cell, *, live: bool) -> Cell:
|
|
120
|
+
cell._sink = self
|
|
121
|
+
if live and not cell.final:
|
|
122
|
+
self.live.append(cell)
|
|
123
|
+
self._invalidate()
|
|
124
|
+
else:
|
|
125
|
+
self._commit(cell)
|
|
126
|
+
return cell
|
|
127
|
+
|
|
128
|
+
def cell_changed(self, cell: Cell) -> None:
|
|
129
|
+
if isinstance(cell, StreamingCell):
|
|
130
|
+
self._drain_stream(cell)
|
|
131
|
+
if cell._closed:
|
|
132
|
+
if cell in self.live: # fully drained into committed chunks
|
|
133
|
+
self.live.remove(cell)
|
|
134
|
+
self._invalidate()
|
|
135
|
+
return
|
|
136
|
+
if cell in self.live and cell.final:
|
|
137
|
+
self._commit(cell)
|
|
138
|
+
else:
|
|
139
|
+
self._invalidate()
|
|
140
|
+
|
|
141
|
+
def _drain_stream(self, cell: StreamingCell) -> None:
|
|
142
|
+
"""Commit finalized chunks of a stream to scrollback so only the live tail
|
|
143
|
+
re-renders each frame. The role label rides the first committed chunk only."""
|
|
144
|
+
while True:
|
|
145
|
+
block = cell.take_committable_block()
|
|
146
|
+
if block is None:
|
|
147
|
+
break
|
|
148
|
+
if not block.strip():
|
|
149
|
+
continue # whitespace-only boundary; skip
|
|
150
|
+
self.emit(
|
|
151
|
+
MessageCell(cell.role, block.strip("\n"), markdown=cell.markdown,
|
|
152
|
+
label=cell.consume_label()),
|
|
153
|
+
live=False,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def cell_remove(self, cell: Cell) -> None:
|
|
157
|
+
if cell in self.live:
|
|
158
|
+
self.live.remove(cell)
|
|
159
|
+
self._invalidate()
|
|
160
|
+
|
|
161
|
+
def _commit(self, cell: Cell) -> None:
|
|
162
|
+
if cell in self.live:
|
|
163
|
+
self.live.remove(cell)
|
|
164
|
+
self.committed.append(cell)
|
|
165
|
+
if self._print_committed is not None:
|
|
166
|
+
self._print_committed(cell)
|
|
167
|
+
self._invalidate()
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------- working spinner
|
|
170
|
+
def spinner(self, label: str):
|
|
171
|
+
engine = self
|
|
172
|
+
|
|
173
|
+
class _Working:
|
|
174
|
+
def __enter__(self_):
|
|
175
|
+
self_.cell = SpinnerCell(label)
|
|
176
|
+
engine.emit(self_.cell, live=True)
|
|
177
|
+
return self_.cell
|
|
178
|
+
def __exit__(self_, *exc):
|
|
179
|
+
self_.cell.remove()
|
|
180
|
+
return _Working()
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------- dispatch
|
|
183
|
+
def set_handler(self, handler: Handler) -> None:
|
|
184
|
+
self._handler = handler
|
|
185
|
+
|
|
186
|
+
def set_on_interrupt(self, hook: InterruptHook) -> None:
|
|
187
|
+
self._on_interrupt = hook
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def busy(self) -> bool:
|
|
191
|
+
return self._current is not None and not self._current.done()
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def queue_depth(self) -> int:
|
|
195
|
+
return self.queue.qsize()
|
|
196
|
+
|
|
197
|
+
def submit_turn(self, text: str) -> None:
|
|
198
|
+
"""Enqueue a prompt for the handler (type-ahead safe). Shown as a muted
|
|
199
|
+
``⋯`` line in the live tail until the dispatcher picks it up."""
|
|
200
|
+
if text:
|
|
201
|
+
self.queue.put_nowait(text)
|
|
202
|
+
self._pending.append(text)
|
|
203
|
+
self._invalidate()
|
|
204
|
+
|
|
205
|
+
async def _run_loop(self) -> None:
|
|
206
|
+
while not self._exit.is_set():
|
|
207
|
+
text = await self.queue.get()
|
|
208
|
+
if self._pending:
|
|
209
|
+
self._pending.pop(0) # it's now running -> stop showing it queued
|
|
210
|
+
assert self._handler is not None
|
|
211
|
+
cancelled = False
|
|
212
|
+
started = time.monotonic()
|
|
213
|
+
self._set_title(working=True)
|
|
214
|
+
self._current = asyncio.create_task(self._handler(text))
|
|
215
|
+
try:
|
|
216
|
+
await self._current
|
|
217
|
+
except asyncio.CancelledError:
|
|
218
|
+
# Distinguish a TURN interrupt (interrupt() cancelled only the child task,
|
|
219
|
+
# so OUR cancelling() count is 0) from the _run_loop task itself being
|
|
220
|
+
# cancelled at shutdown (cancelling() > 0). Re-raise the latter so the loop
|
|
221
|
+
# actually stops instead of swallowing its own cancellation (which hangs exit).
|
|
222
|
+
me = asyncio.current_task()
|
|
223
|
+
if me is not None and me.cancelling() > 0:
|
|
224
|
+
if self._current is not None:
|
|
225
|
+
self._current.cancel()
|
|
226
|
+
raise
|
|
227
|
+
cancelled = True
|
|
228
|
+
if self._on_interrupt is not None:
|
|
229
|
+
try:
|
|
230
|
+
await self._on_interrupt()
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
self.emit(NoteCell("⦻ interrupted"), live=False)
|
|
234
|
+
except Exception as e: # a crashing turn must not kill the session
|
|
235
|
+
self.emit(NoteCell(f"error: {e!r}"), live=False)
|
|
236
|
+
finally:
|
|
237
|
+
self._current = None
|
|
238
|
+
self._finalize_orphans(cancelled)
|
|
239
|
+
self._set_title(working=False)
|
|
240
|
+
elapsed = time.monotonic() - started
|
|
241
|
+
if self._notify_after is not None and elapsed >= self._notify_after:
|
|
242
|
+
self.notify(f"{self._title or 'xli'}: response ready")
|
|
243
|
+
self._invalidate()
|
|
244
|
+
|
|
245
|
+
def _finalize_orphans(self, cancelled: bool) -> None:
|
|
246
|
+
"""Sweep any live cells the handler left behind so nothing stays stuck live.
|
|
247
|
+
|
|
248
|
+
Context-managed cells (streaming, spinner) finalize on block exit / cancel
|
|
249
|
+
unwind; this catches e.g. a ``running`` tool card the handler never closed.
|
|
250
|
+
"""
|
|
251
|
+
for cell in list(self.live):
|
|
252
|
+
if isinstance(cell, SpinnerCell):
|
|
253
|
+
self.live.remove(cell) # transient; its CM normally removes it
|
|
254
|
+
continue
|
|
255
|
+
if cancelled and isinstance(cell, ToolCell) and cell.status == "running":
|
|
256
|
+
cell.status = "cancelled"
|
|
257
|
+
cell.version += 1
|
|
258
|
+
self._commit(cell) # graduate leftovers to scrollback
|
|
259
|
+
|
|
260
|
+
def interrupt(self) -> bool:
|
|
261
|
+
if self.busy:
|
|
262
|
+
self._current.cancel() # type: ignore[union-attr]
|
|
263
|
+
return True
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
def tick(self) -> None:
|
|
267
|
+
dirty = False
|
|
268
|
+
spinners = [c for c in self.live if isinstance(c, SpinnerCell)]
|
|
269
|
+
for c in spinners:
|
|
270
|
+
c.tick()
|
|
271
|
+
dirty = dirty or bool(spinners)
|
|
272
|
+
if self._pet: # advance the ambient pet ~every 0.6s
|
|
273
|
+
self._pet_ticks = (self._pet_ticks + 1) % 6
|
|
274
|
+
if self._pet_ticks == 0:
|
|
275
|
+
self._pet_i = (self._pet_i + 1) % len(self._pet)
|
|
276
|
+
dirty = True
|
|
277
|
+
if dirty:
|
|
278
|
+
self._invalidate()
|
|
279
|
+
|
|
280
|
+
# ------------------------------------------------------- notifications / title / links
|
|
281
|
+
def notify(self, message: str, title: str | None = None) -> None:
|
|
282
|
+
"""Fire a desktop notification (OSC 9, plus OSC 777 for terminals that prefer it)."""
|
|
283
|
+
name = title or self._title or "xli"
|
|
284
|
+
seq = f"\033]9;{message}\a\033]777;notify;{name};{message}\a"
|
|
285
|
+
try:
|
|
286
|
+
sys.stdout.write(seq)
|
|
287
|
+
sys.stdout.flush()
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
def _set_title(self, *, working: bool) -> None:
|
|
292
|
+
if not self._title or self._app is None:
|
|
293
|
+
return
|
|
294
|
+
out = self._app.output
|
|
295
|
+
if hasattr(out, "set_title"):
|
|
296
|
+
out.set_title(f"● {self._title}" if working else self._title)
|
|
297
|
+
|
|
298
|
+
def _pet_fragment(self) -> tuple[str, str] | None:
|
|
299
|
+
if not self._pet:
|
|
300
|
+
return None
|
|
301
|
+
return ("class:pet", self._pet[self._pet_i % len(self._pet)])
|
|
302
|
+
|
|
303
|
+
# ------------------------------------------------------- empty-state intro
|
|
304
|
+
def intro_lines(self, width: int) -> list[str]:
|
|
305
|
+
"""Welcome shown in the live region while the transcript is empty.
|
|
306
|
+
|
|
307
|
+
App-aware: lists the registered commands so users see what's possible.
|
|
308
|
+
``intro=""`` disables it; ``intro="..."`` overrides the hint body.
|
|
309
|
+
"""
|
|
310
|
+
if self._intro == "":
|
|
311
|
+
return []
|
|
312
|
+
from rich.console import Group
|
|
313
|
+
from rich.text import Text
|
|
314
|
+
|
|
315
|
+
t = self.theme
|
|
316
|
+
parts = []
|
|
317
|
+
if self._title:
|
|
318
|
+
parts.append(Text(self._title, style=f"bold {t.assistant_color}"))
|
|
319
|
+
if self._intro:
|
|
320
|
+
parts.append(Text(self._intro, style=t.muted_color))
|
|
321
|
+
else:
|
|
322
|
+
parts.append(Text("Type a message (use @ to mention a file), or a command:",
|
|
323
|
+
style=t.muted_color))
|
|
324
|
+
names = " ".join(f"/{c.name}" for c in self._slash.all())
|
|
325
|
+
if names:
|
|
326
|
+
parts.append(Text(" " + names, style=t.muted_color))
|
|
327
|
+
parts.append(Text("/help for details · esc interrupts · ctrl-d quits", style=t.muted_color))
|
|
328
|
+
lines = render_to_ansi(Group(*parts), width)
|
|
329
|
+
lines.append("") # breathing room between the welcome and the input dock
|
|
330
|
+
return lines
|
|
331
|
+
|
|
332
|
+
# ------------------------------------------------------- inline modals
|
|
333
|
+
#
|
|
334
|
+
# Pattern (matches codex/claude): the *context* commits to scrollback so it
|
|
335
|
+
# auto-scrolls and persists; an arrow-selectable picker shows the choices in the
|
|
336
|
+
# live region; the outcome commits right below the context. Esc cancels.
|
|
337
|
+
|
|
338
|
+
async def _pick(self, options: list[tuple[str, str]]) -> str | None:
|
|
339
|
+
"""Run an inline arrow-selectable picker; return the chosen key (None on esc)."""
|
|
340
|
+
if not options: # nothing to choose -> don't open a dead picker
|
|
341
|
+
return None
|
|
342
|
+
fut: asyncio.Future[str | None] = asyncio.get_running_loop().create_future()
|
|
343
|
+
self._picker = _Picker(options, fut)
|
|
344
|
+
self._invalidate()
|
|
345
|
+
try:
|
|
346
|
+
return await fut
|
|
347
|
+
finally:
|
|
348
|
+
self._picker = None
|
|
349
|
+
self._invalidate()
|
|
350
|
+
|
|
351
|
+
async def approve(self, *, title: str, body: str = "", reason: str = "") -> Decision:
|
|
352
|
+
self.emit(ApprovalCell(title, body, reason), live=False)
|
|
353
|
+
key = await self._pick([
|
|
354
|
+
("approved", "Yes"),
|
|
355
|
+
("approved_for_session", "Yes, and don't ask again"),
|
|
356
|
+
("denied", "No"),
|
|
357
|
+
])
|
|
358
|
+
decision: Decision = key if key is not None else "aborted" # type: ignore[assignment]
|
|
359
|
+
approved = decision in ("approved", "approved_for_session")
|
|
360
|
+
color = self.theme.success_color if approved else self.theme.error_color
|
|
361
|
+
self._commit_result(_DECISION.get(decision, f"→ {decision}"), color)
|
|
362
|
+
return decision
|
|
363
|
+
|
|
364
|
+
async def confirm(self, question: str) -> bool:
|
|
365
|
+
self.emit(NoteCell(question), live=False)
|
|
366
|
+
result = await self._pick([("yes", "Yes"), ("no", "No")]) == "yes"
|
|
367
|
+
self._commit_result(*(("✓ yes", self.theme.success_color) if result
|
|
368
|
+
else ("✗ no", self.theme.error_color)))
|
|
369
|
+
return result
|
|
370
|
+
|
|
371
|
+
async def choose(self, title: str, options: list[tuple[str, str]]) -> str | None:
|
|
372
|
+
if title:
|
|
373
|
+
self.emit(NoteCell(title), live=False)
|
|
374
|
+
key = await self._pick(options)
|
|
375
|
+
if key is not None:
|
|
376
|
+
self._commit_result(f"→ {dict(options).get(key, key)}", self.theme.muted_color)
|
|
377
|
+
return key
|
|
378
|
+
|
|
379
|
+
async def capture_line(self, prompt: str) -> str | None:
|
|
380
|
+
# The composer is the input; record the prompt, then capture the next line.
|
|
381
|
+
self.emit(NoteCell(f"{prompt}\n type your answer below, then enter · esc to cancel"),
|
|
382
|
+
live=False)
|
|
383
|
+
self._line = asyncio.get_running_loop().create_future()
|
|
384
|
+
try:
|
|
385
|
+
value = await self._line
|
|
386
|
+
finally:
|
|
387
|
+
self._line = None
|
|
388
|
+
if value:
|
|
389
|
+
self._commit_result(f"→ {value}", self.theme.muted_color)
|
|
390
|
+
return value
|
|
391
|
+
|
|
392
|
+
def _commit_result(self, label: str, color: str) -> None:
|
|
393
|
+
self.emit(CustomCell(Text(label, style=color)), live=False)
|
|
394
|
+
|
|
395
|
+
def _picker_lines(self, width: int) -> list[str]:
|
|
396
|
+
p = self._picker
|
|
397
|
+
if not p:
|
|
398
|
+
return []
|
|
399
|
+
rows = []
|
|
400
|
+
for i, (_key, label) in enumerate(p.options):
|
|
401
|
+
chosen = i == p.index
|
|
402
|
+
row = Text()
|
|
403
|
+
row.append(f" {'›' if chosen else ' '} {i + 1}. ",
|
|
404
|
+
style=self.theme.command_color if chosen else self.theme.muted_color)
|
|
405
|
+
row.append(label, style=self.theme.command_color if chosen else "default")
|
|
406
|
+
rows.append(row)
|
|
407
|
+
rows.append(Text(" ↑↓ select · enter confirm · esc cancel", style=self.theme.muted_color))
|
|
408
|
+
return render_to_ansi(Group(*rows), width)
|
|
409
|
+
|
|
410
|
+
# ------------------------------------------------------- lifecycle
|
|
411
|
+
def exit(self) -> None:
|
|
412
|
+
self._exit.set()
|
|
413
|
+
if self._app is not None:
|
|
414
|
+
self._app.exit()
|
|
415
|
+
|
|
416
|
+
def run(self) -> None:
|
|
417
|
+
try:
|
|
418
|
+
asyncio.run(self._main())
|
|
419
|
+
except (KeyboardInterrupt, EOFError):
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
async def _main(self) -> None:
|
|
423
|
+
app = self._build_app()
|
|
424
|
+
self._app = app
|
|
425
|
+
self._invalidate = app.invalidate
|
|
426
|
+
self._set_title(working=False) # initial idle window title
|
|
427
|
+
# No printed banner — the empty-state intro (in the live region) is the welcome,
|
|
428
|
+
# and it gets out of the way once the first message lands.
|
|
429
|
+
|
|
430
|
+
def printer(cell: Cell) -> None:
|
|
431
|
+
width = max(20, shutil.get_terminal_size((80, 24)).columns)
|
|
432
|
+
raw = getattr(cell, "raw_emit", None)
|
|
433
|
+
escape = raw(width) if raw is not None else None
|
|
434
|
+
if escape is not None: # graphics-protocol image: print the escape as-is
|
|
435
|
+
print(escape)
|
|
436
|
+
else:
|
|
437
|
+
for ln in cell.lines(width, self.theme):
|
|
438
|
+
print(ln)
|
|
439
|
+
for _ in range(self.theme.item_spacing):
|
|
440
|
+
print()
|
|
441
|
+
self._print_committed = printer
|
|
442
|
+
|
|
443
|
+
async def ticker() -> None:
|
|
444
|
+
while True:
|
|
445
|
+
await asyncio.sleep(0.1)
|
|
446
|
+
self.tick()
|
|
447
|
+
|
|
448
|
+
tasks = [asyncio.create_task(self._run_loop()), asyncio.create_task(ticker())]
|
|
449
|
+
try:
|
|
450
|
+
with patch_stdout(raw=True):
|
|
451
|
+
await app.run_async()
|
|
452
|
+
finally:
|
|
453
|
+
for t in tasks:
|
|
454
|
+
t.cancel()
|
|
455
|
+
await asyncio.gather(*tasks, return_exceptions=True) # clean shutdown, no hang
|
|
456
|
+
|
|
457
|
+
# ------------------------------------------------------- completion (/ and @)
|
|
458
|
+
#
|
|
459
|
+
# Two triggers share one inline list (rendered below the composer): a leading "/"
|
|
460
|
+
# offers slash commands; an "@token" anywhere offers file paths. _completion_context
|
|
461
|
+
# finds the active trigger + the buffer span to replace on accept.
|
|
462
|
+
|
|
463
|
+
def _completion_context(self):
|
|
464
|
+
"""Return (kind, prefix, start, end) for the active completion, or None.
|
|
465
|
+
|
|
466
|
+
kind ∈ {"slash","file"}; [start,end) is the buffer span replaced on accept.
|
|
467
|
+
"""
|
|
468
|
+
if self._buffer is None:
|
|
469
|
+
return None
|
|
470
|
+
text = self._buffer.text
|
|
471
|
+
cursor = self._buffer.cursor_position
|
|
472
|
+
# slash: whole line is "/..." with no space yet (and not an exact command — then
|
|
473
|
+
# the color cue takes over and we hide the list)
|
|
474
|
+
if text.startswith("/") and " " not in text:
|
|
475
|
+
name = text[1:]
|
|
476
|
+
if name and self._slash.get(name.lower()) is not None:
|
|
477
|
+
return None
|
|
478
|
+
return ("slash", name, 0, len(text))
|
|
479
|
+
# file: the whitespace-delimited token ending at the cursor starts with "@"
|
|
480
|
+
before = text[:cursor]
|
|
481
|
+
start = cursor
|
|
482
|
+
while start > 0 and not before[start - 1].isspace():
|
|
483
|
+
start -= 1
|
|
484
|
+
token = text[start:cursor]
|
|
485
|
+
if token.startswith("@"):
|
|
486
|
+
return ("file", token[1:], start, cursor)
|
|
487
|
+
return None
|
|
488
|
+
|
|
489
|
+
def _refresh_completion(self, buff: Buffer) -> None:
|
|
490
|
+
# new text: reset selection to the top and un-dismiss so the list tracks typing
|
|
491
|
+
self._sugg_index = 0
|
|
492
|
+
self._suggest_dismissed = False
|
|
493
|
+
|
|
494
|
+
def _suggestions(self):
|
|
495
|
+
"""Return (ctx, items) where items = list of (label, value, meta)."""
|
|
496
|
+
if self._suggest_dismissed:
|
|
497
|
+
return None, []
|
|
498
|
+
ctx = self._completion_context()
|
|
499
|
+
if ctx is None:
|
|
500
|
+
return None, []
|
|
501
|
+
kind, prefix = ctx[0], ctx[1]
|
|
502
|
+
if kind == "slash":
|
|
503
|
+
items = [(f"/{c.name}", c.name, c.description) for c in self._slash.match("/" + prefix)]
|
|
504
|
+
else:
|
|
505
|
+
items = [(p, p, "") for p in self._file_search(prefix)]
|
|
506
|
+
return ctx, items
|
|
507
|
+
|
|
508
|
+
def _suggest_items(self):
|
|
509
|
+
return self._suggestions()[1]
|
|
510
|
+
|
|
511
|
+
def _suggest_visible(self) -> bool:
|
|
512
|
+
return bool(self._suggest_items())
|
|
513
|
+
|
|
514
|
+
def _suggest_lines(self, width: int) -> list[str]:
|
|
515
|
+
items = self._suggest_items()
|
|
516
|
+
if not items:
|
|
517
|
+
return []
|
|
518
|
+
sel = max(0, min(self._sugg_index, len(items) - 1))
|
|
519
|
+
t = self.theme
|
|
520
|
+
rows = []
|
|
521
|
+
for i, (label, _value, meta) in enumerate(items):
|
|
522
|
+
chosen = i == sel
|
|
523
|
+
row = Text()
|
|
524
|
+
row.append(f" {'›' if chosen else ' '} ",
|
|
525
|
+
style=t.command_color if chosen else t.muted_color)
|
|
526
|
+
row.append(label, style=t.command_color if chosen else t.muted_color)
|
|
527
|
+
if meta:
|
|
528
|
+
row.append(f" {meta}", style=t.muted_color)
|
|
529
|
+
rows.append(row)
|
|
530
|
+
return render_to_ansi(Group(*rows), width)
|
|
531
|
+
|
|
532
|
+
def _move_suggestion(self, delta: int) -> None:
|
|
533
|
+
n = len(self._suggest_items())
|
|
534
|
+
if n:
|
|
535
|
+
self._sugg_index = max(0, min(self._sugg_index + delta, n - 1))
|
|
536
|
+
self._invalidate()
|
|
537
|
+
|
|
538
|
+
def _accept_suggestion(self, *, submit: bool) -> None:
|
|
539
|
+
ctx, items = self._suggestions()
|
|
540
|
+
if not items or ctx is None:
|
|
541
|
+
return
|
|
542
|
+
_label, value, _meta = items[max(0, min(self._sugg_index, len(items) - 1))]
|
|
543
|
+
kind, _prefix, start, end = ctx
|
|
544
|
+
buf = self._buffer
|
|
545
|
+
if kind == "slash":
|
|
546
|
+
if submit: # enter -> run it
|
|
547
|
+
buf.reset() # type: ignore[union-attr]
|
|
548
|
+
self.submit_turn(f"/{value}")
|
|
549
|
+
else: # tab -> fill it in
|
|
550
|
+
buf.text = f"/{value} " # type: ignore[union-attr]
|
|
551
|
+
buf.cursor_position = len(buf.text) # type: ignore[union-attr]
|
|
552
|
+
else: # file: insert "@path ", keep composing
|
|
553
|
+
text = buf.text # type: ignore[union-attr]
|
|
554
|
+
insert = f"@{value} "
|
|
555
|
+
buf.text = text[:start] + insert + text[end:] # type: ignore[union-attr]
|
|
556
|
+
buf.cursor_position = start + len(insert) # type: ignore[union-attr]
|
|
557
|
+
|
|
558
|
+
# ------------------------------------------------------- file search (@mentions)
|
|
559
|
+
_IGNORE_DIRS = {
|
|
560
|
+
".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build",
|
|
561
|
+
".mypy_cache", ".pytest_cache", ".idea", ".tox", "target", ".next",
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
def _all_files(self) -> list[str]:
|
|
565
|
+
if self._file_cache is None:
|
|
566
|
+
import os
|
|
567
|
+
files: list[str] = []
|
|
568
|
+
root = os.getcwd()
|
|
569
|
+
for dp, dns, fns in os.walk(root):
|
|
570
|
+
dns[:] = [d for d in dns if d not in self._IGNORE_DIRS and not d.startswith(".")]
|
|
571
|
+
for fn in fns:
|
|
572
|
+
files.append(os.path.relpath(os.path.join(dp, fn), root))
|
|
573
|
+
if len(files) >= 20000:
|
|
574
|
+
break
|
|
575
|
+
if len(files) >= 20000:
|
|
576
|
+
break
|
|
577
|
+
self._file_cache = files
|
|
578
|
+
return self._file_cache
|
|
579
|
+
|
|
580
|
+
def _file_search(self, prefix: str, limit: int = 12) -> list[str]:
|
|
581
|
+
pl = prefix.lower()
|
|
582
|
+
pre: list[str] = []
|
|
583
|
+
sub: list[str] = []
|
|
584
|
+
for rel in self._all_files():
|
|
585
|
+
rl = rel.lower()
|
|
586
|
+
if not pl or rl.startswith(pl):
|
|
587
|
+
pre.append(rel)
|
|
588
|
+
elif pl in rl:
|
|
589
|
+
sub.append(rel)
|
|
590
|
+
pre.sort(key=lambda p: (len(p), p))
|
|
591
|
+
sub.sort(key=lambda p: (len(p), p))
|
|
592
|
+
return (pre + sub)[:limit]
|
|
593
|
+
|
|
594
|
+
# ------------------------------------------------------- pt app
|
|
595
|
+
def _build_app(self) -> Application:
|
|
596
|
+
# We drive completion ourselves via on_text_changed (which fires on *delete* too,
|
|
597
|
+
# unlike complete_while_typing) so the list re-appears on backspace and hides
|
|
598
|
+
# once a command is fully (exactly) typed. History persists across sessions when a
|
|
599
|
+
# history_file is given; ↑/↓ navigate it (prompt_toolkit's default bindings).
|
|
600
|
+
from pathlib import Path
|
|
601
|
+
history = (FileHistory(str(Path(self._history_file).expanduser()))
|
|
602
|
+
if self._history_file else InMemoryHistory())
|
|
603
|
+
buffer = Buffer(
|
|
604
|
+
multiline=True,
|
|
605
|
+
history=history,
|
|
606
|
+
on_text_changed=self._refresh_completion,
|
|
607
|
+
)
|
|
608
|
+
self._buffer = buffer
|
|
609
|
+
|
|
610
|
+
engine = self
|
|
611
|
+
|
|
612
|
+
def _ansi_content(lines: list[str]) -> UIContent:
|
|
613
|
+
return UIContent(
|
|
614
|
+
get_line=lambda i: to_formatted_text(ANSI(lines[i])),
|
|
615
|
+
line_count=len(lines), show_cursor=False,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
class LiveTail(UIControl):
|
|
619
|
+
def _lines(self, width):
|
|
620
|
+
if (not engine.committed and not engine.live
|
|
621
|
+
and not engine._pending and engine._picker is None):
|
|
622
|
+
return engine.intro_lines(width) # empty-state welcome
|
|
623
|
+
out: list[str] = []
|
|
624
|
+
for cell in engine.live:
|
|
625
|
+
out.extend(cell.lines(width, engine.theme))
|
|
626
|
+
out.extend(engine._picker_lines(width)) # arrow-select modal
|
|
627
|
+
for text in engine._pending: # type-ahead, shown muted
|
|
628
|
+
out.extend(render_to_ansi(
|
|
629
|
+
Text(f"⋯ {text}", style=engine.theme.muted_color), width))
|
|
630
|
+
return out
|
|
631
|
+
|
|
632
|
+
def create_content(self, width, height):
|
|
633
|
+
return _ansi_content(self._lines(width))
|
|
634
|
+
|
|
635
|
+
# required so dont_extend_height sizes the window to its content (not 0)
|
|
636
|
+
def preferred_height(self, width, max_available_height, wrap_lines, get_line_prefix):
|
|
637
|
+
return len(self._lines(width))
|
|
638
|
+
|
|
639
|
+
class Suggest(UIControl):
|
|
640
|
+
def create_content(self, width, height):
|
|
641
|
+
return _ansi_content(engine._suggest_lines(width))
|
|
642
|
+
|
|
643
|
+
def preferred_height(self, width, max_available_height, wrap_lines, get_line_prefix):
|
|
644
|
+
return len(engine._suggest_lines(width))
|
|
645
|
+
|
|
646
|
+
def status_left():
|
|
647
|
+
frags: list[tuple[str, str]] = [
|
|
648
|
+
("class:status.busy" if engine.busy else "class:status.idle",
|
|
649
|
+
" working" if engine.busy else " idle")]
|
|
650
|
+
body = engine._status.render()
|
|
651
|
+
if body:
|
|
652
|
+
frags.append(("class:status", " · "))
|
|
653
|
+
frags.extend(("class:status", t) for _, t in body)
|
|
654
|
+
frags.append(("class:status", " enter send · esc interrupt · ctrl-d quit"))
|
|
655
|
+
return frags
|
|
656
|
+
|
|
657
|
+
class Status(UIControl):
|
|
658
|
+
def create_content(self, width, height):
|
|
659
|
+
frags = list(status_left())
|
|
660
|
+
pet = engine._pet_fragment()
|
|
661
|
+
if pet: # park the pet bottom-right
|
|
662
|
+
used = sum(len(t) for _s, t in frags) + len(pet[1])
|
|
663
|
+
frags.append(("class:status", " " * max(1, width - used)))
|
|
664
|
+
frags.append(pet)
|
|
665
|
+
return UIContent(get_line=lambda i: frags, line_count=1, show_cursor=False)
|
|
666
|
+
|
|
667
|
+
# dont_extend_height keeps every region sized to its CONTENT — without it a
|
|
668
|
+
# flexible window (composer) greedily absorbs vertical slack and pads blank
|
|
669
|
+
# lines below the input.
|
|
670
|
+
live_win = Window(content=LiveTail(), height=Dimension(min=0), dont_extend_height=True)
|
|
671
|
+
sep_win = Window(height=1, char="─", style="class:sep")
|
|
672
|
+
composer_win = Window(
|
|
673
|
+
content=BufferControl(buffer=buffer, lexer=SlashLexer(self._slash)),
|
|
674
|
+
height=Dimension(min=1),
|
|
675
|
+
wrap_lines=True,
|
|
676
|
+
dont_extend_height=True,
|
|
677
|
+
get_line_prefix=lambda *a: [("class:prompt", f" {self.theme.prompt_glyph} ")],
|
|
678
|
+
)
|
|
679
|
+
status_win = Window(content=Status(), height=1)
|
|
680
|
+
|
|
681
|
+
# Command suggestions are our own list rendered directly BELOW the composer
|
|
682
|
+
# (Claude-style) — themed via the rich bridge, no solid-bg popup, no float math.
|
|
683
|
+
# It collapses to 0 height when there's nothing to suggest.
|
|
684
|
+
suggest_win = ConditionalContainer(
|
|
685
|
+
Window(content=Suggest(), height=Dimension(min=0), dont_extend_height=True),
|
|
686
|
+
filter=Condition(lambda: engine._suggest_visible()),
|
|
687
|
+
)
|
|
688
|
+
root = HSplit([live_win, sep_win, composer_win, suggest_win, status_win])
|
|
689
|
+
layout = Layout(root, focused_element=composer_win)
|
|
690
|
+
|
|
691
|
+
return Application(
|
|
692
|
+
layout=layout,
|
|
693
|
+
key_bindings=self._key_bindings(),
|
|
694
|
+
style=self._style(),
|
|
695
|
+
full_screen=False,
|
|
696
|
+
mouse_support=False, # keep native text selection working
|
|
697
|
+
refresh_interval=0.1, # steady repaint for live animations
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
def _key_bindings(self) -> KeyBindings:
|
|
701
|
+
kb = KeyBindings()
|
|
702
|
+
engine = self
|
|
703
|
+
|
|
704
|
+
picking = Condition(lambda: engine._picker is not None)
|
|
705
|
+
capturing = Condition(lambda: engine._line is not None)
|
|
706
|
+
suggesting = Condition(lambda: engine._suggest_visible())
|
|
707
|
+
composing = ~picking & ~capturing # normal composer-editing context
|
|
708
|
+
|
|
709
|
+
# --- line capture (ui.input) ---
|
|
710
|
+
@kb.add("enter", filter=capturing)
|
|
711
|
+
def _(event):
|
|
712
|
+
text = engine._buffer.text # type: ignore[union-attr]
|
|
713
|
+
engine._buffer.reset() # type: ignore[union-attr]
|
|
714
|
+
if engine._line and not engine._line.done():
|
|
715
|
+
engine._line.set_result(text)
|
|
716
|
+
|
|
717
|
+
# --- arrow-selectable picker (approve / confirm / pick / wizard) ---
|
|
718
|
+
@kb.add("up", filter=picking)
|
|
719
|
+
@kb.add("c-p", filter=picking)
|
|
720
|
+
def _(event):
|
|
721
|
+
if engine._picker:
|
|
722
|
+
engine._picker.move(-1)
|
|
723
|
+
engine._invalidate()
|
|
724
|
+
|
|
725
|
+
@kb.add("down", filter=picking)
|
|
726
|
+
@kb.add("c-n", filter=picking)
|
|
727
|
+
def _(event):
|
|
728
|
+
if engine._picker:
|
|
729
|
+
engine._picker.move(1)
|
|
730
|
+
engine._invalidate()
|
|
731
|
+
|
|
732
|
+
@kb.add("enter", filter=picking)
|
|
733
|
+
def _(event):
|
|
734
|
+
p = engine._picker
|
|
735
|
+
if p:
|
|
736
|
+
p.resolve(p.options[p.index][0])
|
|
737
|
+
|
|
738
|
+
def _digit(d: int): # 1-9 quick-select
|
|
739
|
+
@kb.add(str(d), filter=picking)
|
|
740
|
+
def _(event):
|
|
741
|
+
p = engine._picker
|
|
742
|
+
if p and d - 1 < len(p.options):
|
|
743
|
+
p.resolve(p.options[d - 1][0])
|
|
744
|
+
for _d in range(1, 10):
|
|
745
|
+
_digit(_d)
|
|
746
|
+
|
|
747
|
+
# --- command suggestions (only while composing) ---
|
|
748
|
+
@kb.add("enter", filter=suggesting & composing)
|
|
749
|
+
def _(event):
|
|
750
|
+
engine._accept_suggestion(submit=True)
|
|
751
|
+
|
|
752
|
+
@kb.add("tab", filter=suggesting & composing)
|
|
753
|
+
def _(event):
|
|
754
|
+
engine._accept_suggestion(submit=False)
|
|
755
|
+
|
|
756
|
+
@kb.add("down", filter=suggesting & composing)
|
|
757
|
+
def _(event):
|
|
758
|
+
engine._move_suggestion(1)
|
|
759
|
+
|
|
760
|
+
@kb.add("up", filter=suggesting & composing)
|
|
761
|
+
def _(event):
|
|
762
|
+
engine._move_suggestion(-1)
|
|
763
|
+
|
|
764
|
+
# --- normal submit ---
|
|
765
|
+
@kb.add("enter", filter=composing & ~suggesting)
|
|
766
|
+
def _(event):
|
|
767
|
+
text = engine._buffer.text.rstrip() # type: ignore[union-attr]
|
|
768
|
+
if text:
|
|
769
|
+
engine._buffer.append_to_history() # persist for ↑/↓ recall
|
|
770
|
+
engine._buffer.reset() # type: ignore[union-attr]
|
|
771
|
+
if text:
|
|
772
|
+
engine.submit_turn(text)
|
|
773
|
+
|
|
774
|
+
@kb.add("c-j") # newline (also alt+enter)
|
|
775
|
+
@kb.add("escape", "enter")
|
|
776
|
+
def _(event):
|
|
777
|
+
event.current_buffer.insert_text("\n")
|
|
778
|
+
|
|
779
|
+
@kb.add("escape", eager=True)
|
|
780
|
+
def _(event):
|
|
781
|
+
if engine._picker is not None:
|
|
782
|
+
engine._picker.resolve(None) # cancel the choice
|
|
783
|
+
elif engine._line is not None:
|
|
784
|
+
_resolve(engine._line, None)
|
|
785
|
+
elif engine._suggest_visible():
|
|
786
|
+
engine._suggest_dismissed = True # close the command list
|
|
787
|
+
else:
|
|
788
|
+
engine.interrupt()
|
|
789
|
+
|
|
790
|
+
@kb.add("c-c")
|
|
791
|
+
def _(event):
|
|
792
|
+
engine.interrupt()
|
|
793
|
+
|
|
794
|
+
@kb.add("c-d")
|
|
795
|
+
def _(event):
|
|
796
|
+
engine.exit()
|
|
797
|
+
|
|
798
|
+
return kb
|
|
799
|
+
|
|
800
|
+
def _style(self) -> Style:
|
|
801
|
+
t = self.theme
|
|
802
|
+
prompt = _to_pt(t.prompt_color)
|
|
803
|
+
if t.prompt_bg:
|
|
804
|
+
prompt = f"{prompt} bg:{t.prompt_bg}"
|
|
805
|
+
return Style.from_dict({
|
|
806
|
+
"sep": _to_pt(t.muted_color),
|
|
807
|
+
"prompt": prompt or "",
|
|
808
|
+
"slash": _to_pt(t.command_color),
|
|
809
|
+
"status": _to_pt(t.status_color),
|
|
810
|
+
"status.busy": f"{_to_pt(t.warning_color)} bold",
|
|
811
|
+
"status.idle": _to_pt(t.success_color),
|
|
812
|
+
"pet": _to_pt(t.muted_color),
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
class _Picker:
|
|
817
|
+
"""Transient arrow-selectable choice list, rendered in the live region."""
|
|
818
|
+
|
|
819
|
+
def __init__(self, options: list[tuple[str, str]], future: asyncio.Future) -> None:
|
|
820
|
+
self.options = list(options) # [(key, label), ...]
|
|
821
|
+
self.index = 0
|
|
822
|
+
self.future = future
|
|
823
|
+
|
|
824
|
+
def move(self, delta: int) -> None:
|
|
825
|
+
self.index = (self.index + delta) % len(self.options)
|
|
826
|
+
|
|
827
|
+
def resolve(self, key: str | None) -> None:
|
|
828
|
+
if not self.future.done():
|
|
829
|
+
self.future.set_result(key)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _resolve(fut: asyncio.Future | None, value) -> None:
|
|
833
|
+
if fut is not None and not fut.done():
|
|
834
|
+
fut.set_result(value)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
# Decision -> committed result label. The color is resolved from the theme at the call
|
|
838
|
+
# site (success vs error) so it honors the active palette.
|
|
839
|
+
_DECISION = {
|
|
840
|
+
"approved": "✓ approved",
|
|
841
|
+
"approved_for_session": "✓ approved (always)",
|
|
842
|
+
"denied": "✗ denied",
|
|
843
|
+
"aborted": "⦻ aborted",
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
# Rich color name -> prompt_toolkit style fragment (best effort; unknowns pass through).
|
|
848
|
+
_ANSI = {
|
|
849
|
+
"black": "ansiblack", "red": "ansired", "green": "ansigreen", "yellow": "ansiyellow",
|
|
850
|
+
"blue": "ansiblue", "magenta": "ansimagenta", "cyan": "ansicyan", "white": "ansiwhite",
|
|
851
|
+
"grey50": "ansibrightblack", "grey46": "ansibrightblack", "bright_black": "ansibrightblack",
|
|
852
|
+
"bright_red": "ansibrightred", "bright_green": "ansibrightgreen",
|
|
853
|
+
"bright_yellow": "ansibrightyellow", "bright_blue": "ansibrightblue",
|
|
854
|
+
"bright_magenta": "ansibrightmagenta", "bright_cyan": "ansibrightcyan",
|
|
855
|
+
"bright_white": "ansibrightwhite", "default": "",
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _to_pt(rich_color: str) -> str:
|
|
860
|
+
mods, color = [], None
|
|
861
|
+
for part in (rich_color or "").split():
|
|
862
|
+
if part in {"bold", "italic", "underline", "reverse", "dim"}:
|
|
863
|
+
mods.append(part)
|
|
864
|
+
elif part.startswith("#"):
|
|
865
|
+
color = part
|
|
866
|
+
else:
|
|
867
|
+
color = _ANSI.get(part, "")
|
|
868
|
+
return " ".join([*mods, color or ""]).strip()
|