delos-cli 0.1.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.
Files changed (43) hide show
  1. delos_cli/__init__.py +3 -0
  2. delos_cli/agent/__init__.py +34 -0
  3. delos_cli/agent/session.py +111 -0
  4. delos_cli/agent/tools.py +131 -0
  5. delos_cli/agent/transport.py +102 -0
  6. delos_cli/apps/__init__.py +6 -0
  7. delos_cli/apps/base.py +101 -0
  8. delos_cli/apps/chat/__init__.py +5 -0
  9. delos_cli/apps/chat/app.py +149 -0
  10. delos_cli/apps/chat/commands.py +17 -0
  11. delos_cli/apps/chat/render.py +188 -0
  12. delos_cli/apps/chat/replay.py +108 -0
  13. delos_cli/auth/__init__.py +24 -0
  14. delos_cli/auth/config.py +282 -0
  15. delos_cli/auth/mfa.py +120 -0
  16. delos_cli/auth/oauth.py +336 -0
  17. delos_cli/auth/token_manager.py +136 -0
  18. delos_cli/commands/__init__.py +10 -0
  19. delos_cli/commands/base.py +54 -0
  20. delos_cli/commands/builtin.py +160 -0
  21. delos_cli/ctx.py +65 -0
  22. delos_cli/loop.py +19 -0
  23. delos_cli/main.py +230 -0
  24. delos_cli/state.py +28 -0
  25. delos_cli/tools/__init__.py +20 -0
  26. delos_cli/tools/edit_content.py +193 -0
  27. delos_cli/tools/run_shell.py +150 -0
  28. delos_cli/tools/write_content.py +120 -0
  29. delos_cli/transport/__init__.py +24 -0
  30. delos_cli/transport/chats.py +235 -0
  31. delos_cli/transport/client.py +321 -0
  32. delos_cli/transport/models.py +19 -0
  33. delos_cli/ui/__init__.py +6 -0
  34. delos_cli/ui/chat_picker.py +151 -0
  35. delos_cli/ui/completer.py +68 -0
  36. delos_cli/ui/lexer.py +62 -0
  37. delos_cli/ui/output.py +180 -0
  38. delos_cli/ui/repl.py +679 -0
  39. delos_cli/ui/style.py +24 -0
  40. delos_cli-0.1.0.dist-info/METADATA +104 -0
  41. delos_cli-0.1.0.dist-info/RECORD +43 -0
  42. delos_cli-0.1.0.dist-info/WHEEL +4 -0
  43. delos_cli-0.1.0.dist-info/entry_points.txt +2 -0
delos_cli/ui/repl.py ADDED
@@ -0,0 +1,679 @@
1
+ """Custom prompt_toolkit Application: streaming output above, persistent input below.
2
+
3
+ Replaces the per-turn ``prompt_async`` loop so the user can:
4
+
5
+ - See live Markdown rendering as the agent streams text.
6
+ - Type new messages or slash commands while the agent is mid-turn.
7
+ - Hit ``/stop`` (or Ctrl+C) to send the backend stop signal without
8
+ losing the prompt.
9
+
10
+ Layout::
11
+
12
+ ┌── output area (Rich-rendered ANSI, scrollable) ───────────┐
13
+ │ history blocks: tool calls, completed turns │
14
+ │ live block: streaming Markdown (re-rendered each delta) │
15
+ ├──────────────────── separator ────────────────────────────┤
16
+ │ » multiline input │
17
+ ├──────────────────── separator ────────────────────────────┤
18
+ │ region · conv · turn · model · streaming/idle │
19
+ └────────────────────────────────────────────────────────────┘
20
+
21
+ A single asyncio task tracks the in-flight turn (if any). Plain text
22
+ sent while a turn is running is queued via the backend FIFO queue (same
23
+ mechanism the web frontend uses). Slash commands are dispatched
24
+ immediately regardless of streaming state.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import contextlib
31
+ import inspect
32
+ from dataclasses import dataclass
33
+ from typing import TYPE_CHECKING, Any
34
+
35
+ from prompt_toolkit.application import Application, get_app
36
+ from prompt_toolkit.buffer import Buffer
37
+ from prompt_toolkit.data_structures import Point
38
+ from prompt_toolkit.filters import Condition, has_completions
39
+ from prompt_toolkit.formatted_text import HTML
40
+ from prompt_toolkit.key_binding import KeyBindings
41
+ from prompt_toolkit.layout import Layout
42
+ from prompt_toolkit.layout.containers import (
43
+ ConditionalContainer,
44
+ Float,
45
+ FloatContainer,
46
+ HSplit,
47
+ Window,
48
+ )
49
+ from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
50
+ from prompt_toolkit.layout.dimension import Dimension
51
+ from prompt_toolkit.layout.menus import CompletionsMenu
52
+ from prompt_toolkit.layout.processors import BeforeInput
53
+ from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
54
+ from prompt_toolkit.styles import Style
55
+ from rich.text import Text
56
+
57
+ from delos_cli.commands import GLOBAL_COMMANDS, Quit
58
+ from delos_cli.ctx import ConfirmOutcome
59
+ from delos_cli.transport.client import TransportError
60
+ from delos_cli.ui.completer import CommandCompleter
61
+ from delos_cli.ui.output import OutputBuffer
62
+
63
+ if TYPE_CHECKING:
64
+ from collections.abc import Coroutine
65
+
66
+ from prompt_toolkit.key_binding.key_processor import KeyPressEvent
67
+
68
+ from delos_cli.ctx import Ctx
69
+
70
+
71
+ # Keep handles to background tasks so the GC doesn't collect them mid-flight
72
+ # (asyncio holds only weak refs). Pruned on completion via add_done_callback.
73
+ _BG_TASKS: set[asyncio.Task[None]] = set()
74
+
75
+
76
+ def _spawn(coro: Coroutine[Any, Any, None]) -> None:
77
+ """Schedule ``coro`` and retain a strong ref until it finishes."""
78
+ task = asyncio.create_task(coro)
79
+ _BG_TASKS.add(task)
80
+ task.add_done_callback(_BG_TASKS.discard)
81
+
82
+
83
+ class _OutputControl(FormattedTextControl):
84
+ """:class:`FormattedTextControl` that owns scroll handling.
85
+
86
+ We can't lean on the Window's built-in ``_mouse_handler`` because it
87
+ adjusts ``vertical_scroll`` directly, and prompt_toolkit then
88
+ overrides it on the next render based on the cursor position pin we
89
+ use for auto-follow. Instead we intercept scroll events ourselves,
90
+ update ``state`` (cursor line + follow flag), and let the Window
91
+ re-render with the new cursor — which is the exact behavior we want.
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ *args: Any,
97
+ on_scroll: Any,
98
+ **kwargs: Any,
99
+ ) -> None:
100
+ """Wrap the parent control with an ``on_scroll(direction)`` side effect."""
101
+ super().__init__(*args, **kwargs)
102
+ self._on_scroll = on_scroll
103
+
104
+ def mouse_handler(self, mouse_event: MouseEvent) -> Any:
105
+ """Handle scroll events ourselves; defer everything else to the parent."""
106
+ etype = mouse_event.event_type
107
+ if etype == MouseEventType.SCROLL_UP:
108
+ self._on_scroll("up")
109
+ return None
110
+ if etype == MouseEventType.SCROLL_DOWN:
111
+ self._on_scroll("down")
112
+ return None
113
+ return super().mouse_handler(mouse_event)
114
+
115
+
116
+ _STYLE = Style.from_dict({
117
+ "prompt": "fg:ansicyan bold",
118
+ "separator": "fg:#444444",
119
+ "toolbar": "fg:#888888 bg:#1a1a1a",
120
+ "toolbar.streaming": "fg:#fbbf24 bg:#1a1a1a",
121
+ "toolbar.idle": "fg:#888888 bg:#1a1a1a",
122
+ "confirm.body": "fg:#fbbf24",
123
+ "confirm.button": "fg:#888888",
124
+ "confirm.button.selected": "fg:#1a1a1a bg:#fbbf24 bold",
125
+ })
126
+
127
+
128
+ _SCROLL_STEP = 3
129
+ _PAGE_STEP = 10
130
+
131
+
132
+ @dataclass
133
+ class _ConfirmRequest:
134
+ """Live state for an in-app Accept/Deny dialog.
135
+
136
+ Created by :func:`_repl_confirm` (installed on ``ctx.confirm``) when
137
+ a tool handler asks the user to approve a destructive action. The
138
+ REPL replaces the input area with the command preview, an
139
+ Accept / Deny pair of buttons (Tab to toggle), and a freeform
140
+ reason buffer; the keybindings resolve ``future`` and clear the
141
+ request so the layout snaps back to the regular input.
142
+ """
143
+
144
+ body: str
145
+ selected: int # 0 = Accept, 1 = Deny
146
+ future: asyncio.Future[ConfirmOutcome]
147
+
148
+
149
+ class _ReplState:
150
+ """Shared mutable state between the layout and the keybinding handlers."""
151
+
152
+ def __init__(self, ctx: Ctx, output: OutputBuffer) -> None:
153
+ self.ctx = ctx
154
+ self.output = output
155
+ self.input_buffer = Buffer(
156
+ multiline=True,
157
+ completer=CommandCompleter(ctx),
158
+ complete_while_typing=True,
159
+ )
160
+ self.turn_task: asyncio.Task[None] | None = None
161
+ # ``follow`` = True → output Window snaps to bottom on every change.
162
+ # Toggled off when the user scrolls up (mouse wheel, PageUp, Home);
163
+ # toggled back on automatically when scrolling reaches the bottom
164
+ # again, or explicitly via End.
165
+ self.follow: bool = True
166
+ # When ``follow`` is False, the source-line index the output Window
167
+ # focuses on. The Window keeps this line visible (via the cursor
168
+ # position pin), so new streaming content piling up below doesn't
169
+ # yank the user away from where they're reading.
170
+ self.cursor_line: int = 0
171
+ # Active in-app confirmation dialog, if any. Layout swaps the
172
+ # input window for the confirm widget when this is set.
173
+ self.confirm: _ConfirmRequest | None = None
174
+ # Single-line buffer for the optional "reason" field shown
175
+ # underneath the Accept/Deny buttons during confirmation.
176
+ # Reset at the start of each new dialog (cf. ``_repl_confirm``).
177
+ self.reason_buffer = Buffer(multiline=False)
178
+
179
+ def is_streaming(self) -> bool:
180
+ """True iff a turn task is still running."""
181
+ return self.turn_task is not None and not self.turn_task.done()
182
+
183
+ def last_line(self) -> int:
184
+ """Index of the last source line in the output buffer (clamped to 0)."""
185
+ return max(0, self.output.line_count - 1)
186
+
187
+ def scroll_up(self, step: int) -> None:
188
+ """Move the focus line up by ``step``; drops out of follow mode."""
189
+ if self.follow:
190
+ self.follow = False
191
+ self.cursor_line = self.last_line()
192
+ self.cursor_line = max(0, self.cursor_line - step)
193
+
194
+ def scroll_down(self, step: int) -> None:
195
+ """Move the focus line down; re-enters follow mode at the bottom."""
196
+ if self.follow:
197
+ return
198
+ target = self.cursor_line + step
199
+ last = self.last_line()
200
+ if target >= last:
201
+ self.follow = True
202
+ self.cursor_line = last
203
+ else:
204
+ self.cursor_line = target
205
+
206
+
207
+ async def run(ctx: Ctx) -> None:
208
+ """Entry point — build the app, wire it to ``ctx``, run until exit."""
209
+ output = OutputBuffer()
210
+ ctx.output = output
211
+
212
+ state = _ReplState(ctx, output)
213
+ app, input_window, reason_window = _build_application(state)
214
+ output.attach(app)
215
+
216
+ # Install an in-app confirmation prompt so tools can request user
217
+ # approval (``ctx.confirm("preview")``) without spawning a nested
218
+ # PromptSession that fights the live one for the terminal.
219
+ async def _repl_confirm(body: str) -> ConfirmOutcome:
220
+ # Cancel any prior pending request — only one dialog at a time.
221
+ prior = state.confirm
222
+ if prior is not None and not prior.future.done():
223
+ prior.future.set_result(ConfirmOutcome(accepted=False))
224
+ # Reset the reason buffer so old text from a previous denial
225
+ # doesn't leak into the next dialog.
226
+ state.reason_buffer.reset()
227
+ loop = asyncio.get_running_loop()
228
+ future: asyncio.Future[ConfirmOutcome] = loop.create_future()
229
+ state.confirm = _ConfirmRequest(body=body, selected=0, future=future)
230
+ # Move focus onto the reason buffer so the user can type a
231
+ # comment freely; arrow-key bindings in confirm mode bypass
232
+ # buffer cursor movement (we override Tab for button toggle).
233
+ with contextlib.suppress(Exception):
234
+ app.layout.focus(reason_window)
235
+ app.invalidate()
236
+ try:
237
+ return await future
238
+ finally:
239
+ if state.confirm is not None and state.confirm.future is future:
240
+ state.confirm = None
241
+ with contextlib.suppress(Exception):
242
+ app.layout.focus(input_window)
243
+ app.invalidate()
244
+
245
+ ctx.confirm = _repl_confirm
246
+
247
+ _print_banner(state)
248
+ await ctx.app.on_enter(ctx)
249
+
250
+ try:
251
+ await app.run_async()
252
+ finally:
253
+ # Cancel any in-flight turn so the asyncio loop can exit cleanly.
254
+ turn_task = state.turn_task
255
+ if turn_task is not None and not turn_task.done():
256
+ turn_task.cancel()
257
+ with contextlib.suppress(BaseException):
258
+ await turn_task
259
+ ctx.output = None
260
+ ctx.confirm = None
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # Layout + keybindings
265
+ # ---------------------------------------------------------------------------
266
+
267
+
268
+ def _build_application(
269
+ state: _ReplState,
270
+ ) -> tuple[Application[None], Window, Window]:
271
+ """Wire the layout, keybindings, and root Application object.
272
+
273
+ Returns ``(app, input_window, reason_window)`` so the caller can
274
+ swap focus between the regular input and the confirm-dialog reason
275
+ field without reaching into private layout state.
276
+ """
277
+ def _output_cursor() -> Point:
278
+ # Pin an invisible cursor on the line we want to keep in view —
279
+ # the Window's auto-scroll logic adjusts ``vertical_scroll`` to
280
+ # keep this line visible, which gives us auto-follow for free
281
+ # (and stable user-scroll: returning a real line — never None —
282
+ # avoids prompt_toolkit's default Point(0, 0) snap-to-top).
283
+ if state.follow:
284
+ return Point(x=0, y=state.last_line())
285
+ return Point(x=0, y=state.cursor_line)
286
+
287
+ def _on_scroll(direction: str) -> None:
288
+ if direction == "up":
289
+ state.scroll_up(_SCROLL_STEP)
290
+ else: # "down"
291
+ state.scroll_down(_SCROLL_STEP)
292
+
293
+ output_window = Window(
294
+ content=_OutputControl(
295
+ text=state.output.get_ansi,
296
+ get_cursor_position=_output_cursor,
297
+ focusable=False,
298
+ show_cursor=False,
299
+ on_scroll=_on_scroll,
300
+ ),
301
+ wrap_lines=True,
302
+ always_hide_cursor=True,
303
+ )
304
+
305
+ input_window = Window(
306
+ content=BufferControl(
307
+ buffer=state.input_buffer,
308
+ input_processors=[BeforeInput("» ", style="class:prompt")],
309
+ ),
310
+ wrap_lines=True,
311
+ height=Dimension(min=1, max=10),
312
+ )
313
+
314
+ confirm_header = Window(
315
+ content=FormattedTextControl(
316
+ text=lambda: _confirm_text(state),
317
+ focusable=False,
318
+ show_cursor=False,
319
+ ),
320
+ wrap_lines=True,
321
+ height=Dimension(min=3, max=10),
322
+ )
323
+
324
+ # Reason text input — only shown during confirmation. Focused via
325
+ # ``app.layout.focus(reason_window)`` so the user can type freely;
326
+ # custom keybindings (Tab / Enter) handle button toggling and submit.
327
+ reason_window = Window(
328
+ content=BufferControl(
329
+ buffer=state.reason_buffer,
330
+ input_processors=[BeforeInput("reason: ", style="class:confirm.body")],
331
+ ),
332
+ wrap_lines=True,
333
+ height=Dimension(min=1, max=3),
334
+ )
335
+
336
+ is_confirming = Condition(lambda: state.confirm is not None)
337
+ is_idle = Condition(lambda: state.confirm is None)
338
+
339
+ confirm_layout = HSplit(
340
+ [confirm_header, reason_window],
341
+ )
342
+
343
+ toolbar_window = Window(
344
+ content=FormattedTextControl(text=lambda: _toolbar_text(state)),
345
+ height=1,
346
+ style="class:toolbar",
347
+ )
348
+
349
+ body = HSplit([
350
+ output_window,
351
+ Window(height=1, char="─", style="class:separator"),
352
+ ConditionalContainer(input_window, filter=is_idle),
353
+ ConditionalContainer(confirm_layout, filter=is_confirming),
354
+ Window(height=1, char="─", style="class:separator"),
355
+ toolbar_window,
356
+ ])
357
+ # Floating completions menu pinned to the input area. Wrapped in a
358
+ # ConditionalContainer so the float disappears when there's nothing
359
+ # to complete (avoids a stray empty box when you're not typing ``/``).
360
+ root = FloatContainer(
361
+ content=body,
362
+ floats=[
363
+ Float(
364
+ xcursor=True,
365
+ ycursor=True,
366
+ content=ConditionalContainer(
367
+ content=CompletionsMenu(max_height=8, scroll_offset=1),
368
+ filter=has_completions,
369
+ ),
370
+ ),
371
+ ],
372
+ )
373
+
374
+ kb = _build_keybindings(state, output_window)
375
+
376
+ app = Application(
377
+ layout=Layout(root, focused_element=input_window),
378
+ key_bindings=kb,
379
+ style=_STYLE,
380
+ full_screen=True,
381
+ # Capture mouse so scroll wheel works on the output area. Trade-off:
382
+ # plain click-drag selection in the terminal is intercepted; users
383
+ # who want to copy text can hold Shift while selecting on most
384
+ # modern terminals (iTerm2, GNOME Terminal, kitty, …).
385
+ mouse_support=True,
386
+ )
387
+ return app, input_window, reason_window
388
+
389
+
390
+ def _build_keybindings(state: _ReplState, output_window: Window) -> KeyBindings:
391
+ """Bindings for submit, newline, scroll, stop, quit."""
392
+ kb = KeyBindings()
393
+
394
+ is_confirming = Condition(lambda: state.confirm is not None)
395
+ is_idle = Condition(lambda: state.confirm is None)
396
+
397
+ # ── Confirmation dialog (replaces the input area when active) ──
398
+ # Tab toggles between Accept and Deny; left/right stay free for the
399
+ # reason buffer's cursor. ``eager=True`` means these bindings win
400
+ # over the buffer's defaults the moment they match.
401
+ @kb.add("tab", filter=is_confirming, eager=True)
402
+ @kb.add("s-tab", filter=is_confirming, eager=True)
403
+ def _confirm_toggle(event: KeyPressEvent) -> None:
404
+ _ = event
405
+ if state.confirm is not None:
406
+ state.confirm.selected = 1 - state.confirm.selected
407
+
408
+ @kb.add("enter", filter=is_confirming, eager=True)
409
+ def _confirm_submit(event: KeyPressEvent) -> None:
410
+ _ = event
411
+ req = state.confirm
412
+ if req is None or req.future.done():
413
+ return
414
+ if req.selected == 0:
415
+ outcome = ConfirmOutcome(accepted=True)
416
+ else:
417
+ outcome = ConfirmOutcome(
418
+ accepted=False,
419
+ reason=state.reason_buffer.text.strip(),
420
+ )
421
+ req.future.set_result(outcome)
422
+
423
+ @kb.add("escape", filter=is_confirming, eager=True)
424
+ def _confirm_cancel(event: KeyPressEvent) -> None:
425
+ _ = event
426
+ req = state.confirm
427
+ if req is None or req.future.done():
428
+ return
429
+ # Esc = quick deny without reason, regardless of what's typed.
430
+ req.future.set_result(ConfirmOutcome(accepted=False))
431
+
432
+ # ── Normal input mode (only when no confirm dialog is up) ──
433
+ @kb.add("enter", filter=is_idle)
434
+ def _submit(event: KeyPressEvent) -> None:
435
+ _ = event
436
+ text = state.input_buffer.text
437
+ if not text.strip():
438
+ return
439
+ state.input_buffer.reset()
440
+ _spawn(_dispatch_input(state, text))
441
+
442
+ @kb.add("escape", "enter", filter=is_idle)
443
+ def _newline(event: KeyPressEvent) -> None:
444
+ _ = event
445
+ state.input_buffer.insert_text("\n")
446
+
447
+ @kb.add("escape", filter=is_idle)
448
+ def _escape(event: KeyPressEvent) -> None:
449
+ # Plain Esc waits ~100ms (default escape timeout) for sequences like
450
+ # `escape, enter`, then fires here. Streaming → stop the turn.
451
+ # Idle with text in the input → clear the input. Idle empty → no-op.
452
+ _ = event
453
+ if state.is_streaming():
454
+ _spawn(_handle_stop(state))
455
+ elif state.input_buffer.text:
456
+ state.input_buffer.reset()
457
+
458
+ @kb.add("c-c")
459
+ def _ctrl_c(event: KeyPressEvent) -> None:
460
+ # Don't let Ctrl+C kill the CLI by accident — it's the muscle
461
+ # memory for "stop the LLM" but exiting the whole REPL is a much
462
+ # bigger blast radius. Stop the run if streaming; otherwise hint.
463
+ _ = event
464
+ if state.is_streaming():
465
+ _spawn(_handle_stop(state))
466
+ else:
467
+ state.output.print(
468
+ Text(
469
+ "(Esc to stop a turn · Ctrl+D to quit)",
470
+ style="dim yellow",
471
+ ),
472
+ )
473
+
474
+ @kb.add("c-d")
475
+ def _ctrl_d(event: KeyPressEvent) -> None:
476
+ event.app.exit()
477
+
478
+ @kb.add("pageup")
479
+ def _page_up(event: KeyPressEvent) -> None:
480
+ _ = event
481
+ state.scroll_up(_PAGE_STEP)
482
+
483
+ @kb.add("pagedown")
484
+ def _page_down(event: KeyPressEvent) -> None:
485
+ _ = event
486
+ state.scroll_down(_PAGE_STEP)
487
+
488
+ @kb.add("end")
489
+ def _end(event: KeyPressEvent) -> None:
490
+ _ = event
491
+ # Re-engage auto-follow — cursor pin snaps to latest line.
492
+ state.follow = True
493
+
494
+ @kb.add("home")
495
+ def _home(event: KeyPressEvent) -> None:
496
+ _ = event
497
+ state.follow = False
498
+ state.cursor_line = 0
499
+
500
+ _ = output_window # kept in scope for the layout; no manual scroll needed
501
+ return kb
502
+
503
+
504
+ # ---------------------------------------------------------------------------
505
+ # Toolbar
506
+ # ---------------------------------------------------------------------------
507
+
508
+
509
+ _NAME_DISPLAY_MAX = 50
510
+
511
+
512
+ def _confirm_text(state: _ReplState) -> HTML:
513
+ """Render the confirmation widget header — preview + Accept/Deny buttons.
514
+
515
+ The reason input lives in its own ``Window`` (a Buffer) below this
516
+ block so the user can type and edit a comment freely.
517
+ """
518
+ req = state.confirm
519
+ if req is None:
520
+ return HTML("")
521
+ safe = req.body.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
522
+ accept_class = "confirm.button.selected" if req.selected == 0 else "confirm.button"
523
+ deny_class = "confirm.button.selected" if req.selected == 1 else "confirm.button"
524
+ return HTML(
525
+ f"<confirm.body>$ {safe}</confirm.body>\n"
526
+ f"\n"
527
+ f" <{accept_class}> Accept </{accept_class}>"
528
+ f" "
529
+ f"<{deny_class}> Deny </{deny_class}>"
530
+ f" <i>tab to switch · enter to confirm · esc to cancel · "
531
+ f"type a reason below to send it with deny</i>",
532
+ )
533
+
534
+
535
+ def _toolbar_text(state: _ReplState) -> HTML:
536
+ """Compose the bottom-toolbar HTML (re-evaluated on every redraw)."""
537
+ ctx = state.ctx
538
+ name = ctx.state.name
539
+ if name:
540
+ label = name if len(name) <= _NAME_DISPLAY_MAX else name[: _NAME_DISPLAY_MAX - 1] + "…"
541
+ else:
542
+ label = "conv " + ctx.state.conv_id.split("-", 1)[0]
543
+ streaming_class = "toolbar.streaming" if state.is_streaming() else "toolbar.idle"
544
+ streaming_label = "● streaming" if state.is_streaming() else "○ idle"
545
+ scroll_part = "" if state.follow else " · <b>↑ scrolled (End to follow)</b>"
546
+ return HTML(
547
+ f" <b>{ctx.cfg.region}</b> · "
548
+ f"<b>{label}</b> · "
549
+ f"<{streaming_class}>{streaming_label}</{streaming_class}>"
550
+ f"{scroll_part}",
551
+ )
552
+
553
+
554
+ # ---------------------------------------------------------------------------
555
+ # Input dispatch
556
+ # ---------------------------------------------------------------------------
557
+
558
+
559
+ async def _dispatch_input(state: _ReplState, text: str) -> None:
560
+ """Route a submitted line: slash command, queue, or new turn."""
561
+ line = text.strip()
562
+ if not line:
563
+ return
564
+
565
+ if line.startswith("/") or line == ":q":
566
+ try:
567
+ await _dispatch_command(state, line)
568
+ except Quit:
569
+ _exit_app(state)
570
+ return
571
+
572
+ if state.is_streaming():
573
+ await _queue_during_stream(state, line)
574
+ return
575
+
576
+ # Drain any prior task so its `finally` cleanup ran before we start a new one.
577
+ if state.turn_task is not None:
578
+ with contextlib.suppress(BaseException):
579
+ await state.turn_task
580
+
581
+ state.turn_task = asyncio.create_task(_run_turn(state, line))
582
+ # Force a final redraw once the task is fully done — the last
583
+ # ``output.invalidate()`` from inside the stream fires BEFORE the
584
+ # task is marked done, so without this nudge the toolbar would keep
585
+ # showing "● streaming" until the user typed something.
586
+ state.turn_task.add_done_callback(lambda _t: _safe_invalidate())
587
+
588
+
589
+ def _safe_invalidate() -> None:
590
+ """Trigger an Application redraw, swallowing any "no app running" error."""
591
+ with contextlib.suppress(Exception):
592
+ get_app().invalidate()
593
+
594
+
595
+ async def _dispatch_command(state: _ReplState, line: str) -> None:
596
+ """Look up the command spec and call its handler."""
597
+ head, _, args = line.partition(" ")
598
+ spec = state.ctx.app.commands.get(head) or GLOBAL_COMMANDS.get(head)
599
+ if spec is None:
600
+ state.output.print(Text(f"unknown command {head} — try /help", style="yellow"))
601
+ return
602
+ result = spec.handler(state.ctx, args.strip())
603
+ if inspect.isawaitable(result):
604
+ await result
605
+
606
+
607
+ async def _queue_during_stream(state: _ReplState, content: str) -> None:
608
+ """Append a plain message to the backend FIFO queue."""
609
+ try:
610
+ queued = await state.ctx.app.queue_message(state.ctx, content)
611
+ except Exception as e:
612
+ state.output.print(Text(f"queue failed: {e}", style="red"))
613
+ return
614
+ if queued:
615
+ state.output.print(Text(f"[queued] {content}", style="dim cyan"))
616
+ else:
617
+ state.output.print(
618
+ Text(
619
+ "(no active turn to queue against — wait for the prompt or send /stop)",
620
+ style="yellow",
621
+ ),
622
+ )
623
+
624
+
625
+ async def _handle_stop(state: _ReplState) -> None:
626
+ """``/stop`` invoked or Ctrl+C while streaming."""
627
+ try:
628
+ await state.ctx.app.cancel(state.ctx)
629
+ except Exception as e:
630
+ state.output.print(Text(f"stop failed: {e}", style="yellow"))
631
+ return
632
+ state.output.print(Text("stop signal sent", style="dim"))
633
+
634
+
635
+ async def _run_turn(state: _ReplState, line: str) -> None:
636
+ """Run one user turn end-to-end, pushing every event through the renderer."""
637
+ ctx = state.ctx
638
+ ctx.state.turn += 1
639
+ new_message = [{"role": "user", "content": line}]
640
+ # Echo the user message into the buffer so the conversation reads cleanly.
641
+ state.output.print(Text(f"» {line}", style="bold cyan"))
642
+
643
+ renderer = ctx.app.make_renderer(state.output)
644
+ try:
645
+ async for event in ctx.app.send(ctx, new_message):
646
+ renderer.apply(event)
647
+ except TransportError as e:
648
+ state.output.print(Text(str(e), style="red"))
649
+ ctx.state.turn -= 1
650
+ except asyncio.CancelledError:
651
+ with contextlib.suppress(Exception):
652
+ await ctx.app.cancel(ctx)
653
+ state.output.print(Text("[cancelled]", style="dim"))
654
+ ctx.state.turn -= 1
655
+ raise
656
+ finally:
657
+ renderer.close()
658
+
659
+
660
+ # ---------------------------------------------------------------------------
661
+ # Banner / exit
662
+ # ---------------------------------------------------------------------------
663
+
664
+
665
+ def _print_banner(state: _ReplState) -> None:
666
+ """Print the startup banner into the output buffer."""
667
+ cfg = state.ctx.cfg
668
+ state.output.print(Text(f"delos {cfg.region} · {cfg.api_url}", style="dim"))
669
+ state.output.print(Text(f"conversation {state.ctx.state.conv_id}", style="dim"))
670
+ state.output.print(
671
+ Text("/help for commands — ctrl-c stop · ctrl-d quit", style="dim"),
672
+ )
673
+
674
+
675
+ def _exit_app(state: _ReplState) -> None:
676
+ """Trigger the Application to exit on the next event-loop tick."""
677
+ _ = state
678
+ with contextlib.suppress(Exception):
679
+ get_app().exit()
delos_cli/ui/style.py ADDED
@@ -0,0 +1,24 @@
1
+ """Central prompt_toolkit style dict.
2
+
3
+ Keep all terminal colors here so the REPL has one surface to tune when
4
+ themes drift. CSS-class-style dotted names match the tokens emitted by
5
+ ``ui/lexer.py``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from prompt_toolkit.styles import Style
11
+
12
+ # Muted grays for secondary UI (placeholder, toolbar hints).
13
+ # Explicit hex works across dark + light terminal themes.
14
+ PLACEHOLDER_FG = "#5c5c5c"
15
+ HINT_FG = "#6c6c6c"
16
+
17
+ STYLE = Style.from_dict(
18
+ {
19
+ "repl.cmd": "ansicyan bold",
20
+ "repl.mention": "ansimagenta",
21
+ "repl.url": "ansiblue underline",
22
+ "repl.fence": "ansiyellow",
23
+ },
24
+ )