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.
- delos_cli/__init__.py +3 -0
- delos_cli/agent/__init__.py +34 -0
- delos_cli/agent/session.py +111 -0
- delos_cli/agent/tools.py +131 -0
- delos_cli/agent/transport.py +102 -0
- delos_cli/apps/__init__.py +6 -0
- delos_cli/apps/base.py +101 -0
- delos_cli/apps/chat/__init__.py +5 -0
- delos_cli/apps/chat/app.py +149 -0
- delos_cli/apps/chat/commands.py +17 -0
- delos_cli/apps/chat/render.py +188 -0
- delos_cli/apps/chat/replay.py +108 -0
- delos_cli/auth/__init__.py +24 -0
- delos_cli/auth/config.py +282 -0
- delos_cli/auth/mfa.py +120 -0
- delos_cli/auth/oauth.py +336 -0
- delos_cli/auth/token_manager.py +136 -0
- delos_cli/commands/__init__.py +10 -0
- delos_cli/commands/base.py +54 -0
- delos_cli/commands/builtin.py +160 -0
- delos_cli/ctx.py +65 -0
- delos_cli/loop.py +19 -0
- delos_cli/main.py +230 -0
- delos_cli/state.py +28 -0
- delos_cli/tools/__init__.py +20 -0
- delos_cli/tools/edit_content.py +193 -0
- delos_cli/tools/run_shell.py +150 -0
- delos_cli/tools/write_content.py +120 -0
- delos_cli/transport/__init__.py +24 -0
- delos_cli/transport/chats.py +235 -0
- delos_cli/transport/client.py +321 -0
- delos_cli/transport/models.py +19 -0
- delos_cli/ui/__init__.py +6 -0
- delos_cli/ui/chat_picker.py +151 -0
- delos_cli/ui/completer.py +68 -0
- delos_cli/ui/lexer.py +62 -0
- delos_cli/ui/output.py +180 -0
- delos_cli/ui/repl.py +679 -0
- delos_cli/ui/style.py +24 -0
- delos_cli-0.1.0.dist-info/METADATA +104 -0
- delos_cli-0.1.0.dist-info/RECORD +43 -0
- delos_cli-0.1.0.dist-info/WHEEL +4 -0
- 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("&", "&").replace("<", "<").replace(">", ">")
|
|
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
|
+
)
|