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