dscli-agent 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.
- dscli/__init__.py +3 -0
- dscli/__main__.py +4 -0
- dscli/agent.py +312 -0
- dscli/attachments.py +122 -0
- dscli/browser.py +1354 -0
- dscli/cli.py +842 -0
- dscli/config.py +191 -0
- dscli/display.py +108 -0
- dscli/prompts.py +263 -0
- dscli/protocol.py +633 -0
- dscli/scraper.py +268 -0
- dscli/tools.py +674 -0
- dscli/tui.py +574 -0
- dscli/ui.py +72 -0
- dscli_agent-0.1.0.dist-info/METADATA +379 -0
- dscli_agent-0.1.0.dist-info/RECORD +20 -0
- dscli_agent-0.1.0.dist-info/WHEEL +5 -0
- dscli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- dscli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- dscli_agent-0.1.0.dist-info/top_level.txt +1 -0
dscli/tui.py
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Callable
|
|
7
|
+
|
|
8
|
+
from prompt_toolkit import PromptSession
|
|
9
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
10
|
+
from prompt_toolkit.history import FileHistory, InMemoryHistory
|
|
11
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
12
|
+
from prompt_toolkit.styles import Style
|
|
13
|
+
from rich.console import Console, Group
|
|
14
|
+
from rich.live import Live
|
|
15
|
+
from rich.markdown import Markdown
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .config import Config, config_dir
|
|
21
|
+
from .protocol import ToolCall
|
|
22
|
+
from .tools import ToolResult
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from .agent import AgentOutcome
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
SLASH_COMMANDS = {
|
|
29
|
+
"/help": "Show commands and shortcuts",
|
|
30
|
+
"/new": "Start a fresh conversation",
|
|
31
|
+
"/exit": "Exit dscli",
|
|
32
|
+
"/quit": "Exit dscli",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_SPINNER_FRAMES = "✢✳✶✻✽✻✶✳"
|
|
36
|
+
_THINKING_VERBS = (
|
|
37
|
+
"Thinking",
|
|
38
|
+
"Pondering",
|
|
39
|
+
"Working",
|
|
40
|
+
"Reasoning",
|
|
41
|
+
"Checking",
|
|
42
|
+
"Composing",
|
|
43
|
+
)
|
|
44
|
+
_TOOL_NAMES = {
|
|
45
|
+
"read_file": ("Reading", "Read"),
|
|
46
|
+
"write_file": ("Writing", "Write"),
|
|
47
|
+
"apply_patch": ("Updating", "Update"),
|
|
48
|
+
"list_files": ("Listing", "List"),
|
|
49
|
+
"delete_file": ("Deleting", "Delete"),
|
|
50
|
+
"run_command": ("Running", "Run"),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TuiQuit(KeyboardInterrupt):
|
|
55
|
+
"""The user chose q at an approval prompt; exit instead of continuing."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SlashCompleter(Completer):
|
|
59
|
+
def get_completions(self, document, complete_event):
|
|
60
|
+
text = document.text_before_cursor
|
|
61
|
+
if not text.startswith("/") or " " in text or "\n" in text:
|
|
62
|
+
return
|
|
63
|
+
for name, description in SLASH_COMMANDS.items():
|
|
64
|
+
if name.startswith(text):
|
|
65
|
+
yield Completion(
|
|
66
|
+
name,
|
|
67
|
+
start_position=-len(text),
|
|
68
|
+
display=name,
|
|
69
|
+
display_meta=description,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _shorten(text: str, limit: int = 100) -> str:
|
|
74
|
+
text = text.replace("\r", " ").replace("\n", " ").strip()
|
|
75
|
+
return text if len(text) <= limit else text[: limit - 3] + "..."
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _tool_target(name: str, args: dict | None, limit: int = 110) -> str:
|
|
79
|
+
args = args or {}
|
|
80
|
+
if name == "run_command":
|
|
81
|
+
return _shorten(str(args.get("command", "")), limit)
|
|
82
|
+
path = args.get("path")
|
|
83
|
+
if path is None:
|
|
84
|
+
return ""
|
|
85
|
+
extra = ""
|
|
86
|
+
if args.get("start_line") or args.get("end_line"):
|
|
87
|
+
extra = f" lines {args.get('start_line', '')}-{args.get('end_line', '')}".rstrip("-")
|
|
88
|
+
return f"{path}{extra}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _tool_title(name: str) -> str:
|
|
92
|
+
return _TOOL_NAMES.get(name, ("", name))[1]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Tui:
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
*,
|
|
99
|
+
workspace: Path,
|
|
100
|
+
auto_approve: bool = False,
|
|
101
|
+
quiet: bool = False,
|
|
102
|
+
console: Console | None = None,
|
|
103
|
+
prompt_session: PromptSession | None = None,
|
|
104
|
+
approval_session: PromptSession | None = None,
|
|
105
|
+
clock: Callable[[], float] = time.monotonic,
|
|
106
|
+
) -> None:
|
|
107
|
+
self.workspace = workspace
|
|
108
|
+
self.quiet = quiet
|
|
109
|
+
self._auto = auto_approve
|
|
110
|
+
self._clock = clock
|
|
111
|
+
self.console = console or Console(
|
|
112
|
+
highlight=False,
|
|
113
|
+
no_color=os.environ.get("NO_COLOR") is not None,
|
|
114
|
+
)
|
|
115
|
+
self._live: Live | None = None
|
|
116
|
+
self._answer_live: Live | None = None
|
|
117
|
+
self._status_text = ""
|
|
118
|
+
self._last_status_text = ""
|
|
119
|
+
self._status_started = 0.0
|
|
120
|
+
self._answer_target = ""
|
|
121
|
+
self._answer_visible = ""
|
|
122
|
+
self._answer_started = 0.0
|
|
123
|
+
self._answer_tick = 0.0
|
|
124
|
+
self._prompt_session = prompt_session or self._build_prompt_session()
|
|
125
|
+
self._approval_session = approval_session or self._build_approval_session()
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------- prompt
|
|
128
|
+
def _history(self):
|
|
129
|
+
try:
|
|
130
|
+
path = config_dir() / "chat_history"
|
|
131
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
return FileHistory(str(path))
|
|
133
|
+
except OSError:
|
|
134
|
+
return InMemoryHistory()
|
|
135
|
+
|
|
136
|
+
def _style(self) -> Style:
|
|
137
|
+
if os.environ.get("NO_COLOR") is not None:
|
|
138
|
+
return Style.from_dict({})
|
|
139
|
+
return Style.from_dict(
|
|
140
|
+
{
|
|
141
|
+
"prompt": "ansicyan bold",
|
|
142
|
+
"bottom-toolbar": "bg:#202020 #909090",
|
|
143
|
+
"completion-menu.completion": "bg:#1c1c1c #d0d0d0",
|
|
144
|
+
"completion-menu.completion.current": "bg:#444444 #ffffff",
|
|
145
|
+
"completion-menu.meta.completion": "bg:#1c1c1c #888888",
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def _key_bindings(self) -> KeyBindings:
|
|
150
|
+
bindings = KeyBindings()
|
|
151
|
+
|
|
152
|
+
@bindings.add("enter")
|
|
153
|
+
def _(event) -> None:
|
|
154
|
+
buffer = event.current_buffer
|
|
155
|
+
if buffer.complete_state and buffer.text.strip() not in SLASH_COMMANDS:
|
|
156
|
+
completion = buffer.complete_state.current_completion
|
|
157
|
+
if completion is not None:
|
|
158
|
+
buffer.apply_completion(completion)
|
|
159
|
+
return
|
|
160
|
+
buffer.validate_and_handle()
|
|
161
|
+
|
|
162
|
+
@bindings.add("escape", "enter")
|
|
163
|
+
def _(event) -> None:
|
|
164
|
+
event.current_buffer.insert_text("\n")
|
|
165
|
+
|
|
166
|
+
@bindings.add("c-j")
|
|
167
|
+
def _(event) -> None:
|
|
168
|
+
event.current_buffer.insert_text("\n")
|
|
169
|
+
|
|
170
|
+
@bindings.add("c-d")
|
|
171
|
+
def _(event) -> None:
|
|
172
|
+
buffer = event.current_buffer
|
|
173
|
+
if buffer.text:
|
|
174
|
+
buffer.delete()
|
|
175
|
+
else:
|
|
176
|
+
event.app.exit(exception=EOFError, style="class:exiting")
|
|
177
|
+
|
|
178
|
+
return bindings
|
|
179
|
+
|
|
180
|
+
def _build_prompt_session(self) -> PromptSession:
|
|
181
|
+
return PromptSession(
|
|
182
|
+
history=self._history(),
|
|
183
|
+
completer=SlashCompleter(),
|
|
184
|
+
complete_while_typing=True,
|
|
185
|
+
multiline=True,
|
|
186
|
+
key_bindings=self._key_bindings(),
|
|
187
|
+
style=self._style(),
|
|
188
|
+
bottom_toolbar=(
|
|
189
|
+
" /help commands · Enter send · Esc+Enter newline · Ctrl+D exit "
|
|
190
|
+
),
|
|
191
|
+
prompt_continuation="... ",
|
|
192
|
+
enable_open_in_editor=False,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def _build_approval_session(self) -> PromptSession:
|
|
196
|
+
return PromptSession(
|
|
197
|
+
history=InMemoryHistory(),
|
|
198
|
+
style=self._style(),
|
|
199
|
+
enable_open_in_editor=False,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def prompt(self) -> str:
|
|
203
|
+
self._stop_live()
|
|
204
|
+
# Playwright's sync API leaves an asyncio loop running on this thread,
|
|
205
|
+
# so prompt_toolkit needs its own loop in a worker thread.
|
|
206
|
+
return self._prompt_session.prompt(
|
|
207
|
+
[("class:prompt", "❯ ")], in_thread=True
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# -------------------------------------------------------------- lifecycle
|
|
211
|
+
def banner(self, cfg: Config) -> None:
|
|
212
|
+
self._stop_live()
|
|
213
|
+
modes = []
|
|
214
|
+
if cfg.deepthink:
|
|
215
|
+
modes.append("deepthink")
|
|
216
|
+
if cfg.search:
|
|
217
|
+
modes.append("search")
|
|
218
|
+
if cfg.allow_shell:
|
|
219
|
+
modes.append("shell allowed")
|
|
220
|
+
mode_text = " · ".join(modes) if modes else ""
|
|
221
|
+
self.console.print()
|
|
222
|
+
self.console.print(
|
|
223
|
+
Text.assemble(
|
|
224
|
+
(" ✻ ", "bold cyan"),
|
|
225
|
+
("Welcome to dscli", "bold"),
|
|
226
|
+
(f" {__version__}", "dim"),
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
intro = Text(" local coding agent", style="dim")
|
|
230
|
+
if mode_text:
|
|
231
|
+
intro.append(f" · {mode_text}", style="dim")
|
|
232
|
+
self.console.print(intro)
|
|
233
|
+
self.console.print(
|
|
234
|
+
Text.assemble((" workspace ", "dim"), (str(self.workspace), "bold"))
|
|
235
|
+
)
|
|
236
|
+
self.console.print(
|
|
237
|
+
Text(" /help for commands · Ctrl+D to exit", style="dim")
|
|
238
|
+
)
|
|
239
|
+
self.console.print()
|
|
240
|
+
|
|
241
|
+
def print_help(self) -> None:
|
|
242
|
+
self._stop_live()
|
|
243
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
244
|
+
for name, description in SLASH_COMMANDS.items():
|
|
245
|
+
table.add_row(Text(name, style="cyan"), Text(description))
|
|
246
|
+
self.console.print(table)
|
|
247
|
+
self.console.print(
|
|
248
|
+
Text(
|
|
249
|
+
"Enter sends · Esc+Enter or Ctrl+J adds a line · "
|
|
250
|
+
"Ctrl+C interrupts while working or clears the prompt · Ctrl+D exits",
|
|
251
|
+
style="dim",
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def system(self, message: str) -> None:
|
|
256
|
+
self._stop_live()
|
|
257
|
+
self.console.print(Text(message, style="dim"))
|
|
258
|
+
|
|
259
|
+
def close(self) -> None:
|
|
260
|
+
self._stop_live()
|
|
261
|
+
|
|
262
|
+
def interrupted(self) -> None:
|
|
263
|
+
partial = self._answer_target
|
|
264
|
+
self._stop_live()
|
|
265
|
+
if partial.strip():
|
|
266
|
+
try:
|
|
267
|
+
self.console.print(Markdown(partial))
|
|
268
|
+
except Exception:
|
|
269
|
+
self.console.print(Text(partial))
|
|
270
|
+
self.console.print(
|
|
271
|
+
Text(
|
|
272
|
+
" Interrupted. Session kept; send another message or use /new.",
|
|
273
|
+
style="yellow",
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def turn_failed(self, message: str, changed: list[str]) -> None:
|
|
278
|
+
self._stop_live()
|
|
279
|
+
self.console.print(Text(f"Error: {message}", style="bold red"))
|
|
280
|
+
if changed:
|
|
281
|
+
self.console.print(
|
|
282
|
+
Text("Files changed: " + ", ".join(changed), style="dim")
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# ---------------------------------------------------------------- status
|
|
286
|
+
def start_status(self, text: str) -> None:
|
|
287
|
+
self._start_status(text)
|
|
288
|
+
|
|
289
|
+
def update_status(self, text: str) -> None:
|
|
290
|
+
self._start_status(text)
|
|
291
|
+
|
|
292
|
+
def stop_status(self) -> None:
|
|
293
|
+
self._stop_status()
|
|
294
|
+
|
|
295
|
+
def _start_status(self, text: str) -> None:
|
|
296
|
+
if self.quiet or not text:
|
|
297
|
+
return
|
|
298
|
+
self._status_text = text
|
|
299
|
+
self._last_status_text = text
|
|
300
|
+
if self._live is not None:
|
|
301
|
+
try:
|
|
302
|
+
self._live.refresh()
|
|
303
|
+
except Exception:
|
|
304
|
+
self._stop_status()
|
|
305
|
+
return
|
|
306
|
+
self._status_started = self._clock()
|
|
307
|
+
self._live = Live(
|
|
308
|
+
get_renderable=self._render_status,
|
|
309
|
+
console=self.console,
|
|
310
|
+
transient=True,
|
|
311
|
+
refresh_per_second=12,
|
|
312
|
+
vertical_overflow="ellipsis",
|
|
313
|
+
redirect_stdout=False,
|
|
314
|
+
redirect_stderr=False,
|
|
315
|
+
)
|
|
316
|
+
try:
|
|
317
|
+
self._live.start(refresh=True)
|
|
318
|
+
except Exception:
|
|
319
|
+
self._live = None
|
|
320
|
+
|
|
321
|
+
def _frame(self, elapsed: float) -> str:
|
|
322
|
+
return _SPINNER_FRAMES[int(elapsed * 8) % len(_SPINNER_FRAMES)]
|
|
323
|
+
|
|
324
|
+
def _status_line(self, label: str, elapsed: float, detail: str = "") -> Text:
|
|
325
|
+
line = Text()
|
|
326
|
+
line.append(self._frame(elapsed), style="cyan")
|
|
327
|
+
line.append(f" {label}", style="bold")
|
|
328
|
+
line.append(f" {elapsed:.0f}s", style="dim")
|
|
329
|
+
if detail:
|
|
330
|
+
line.append(f" · {detail}", style="dim")
|
|
331
|
+
line.append(" · Ctrl+C to interrupt", style="dim")
|
|
332
|
+
return line
|
|
333
|
+
|
|
334
|
+
def _render_status(self):
|
|
335
|
+
elapsed = max(0.0, self._clock() - self._status_started)
|
|
336
|
+
label = self._status_text
|
|
337
|
+
if label == "Thinking":
|
|
338
|
+
label = _THINKING_VERBS[int(elapsed // 3.0) % len(_THINKING_VERBS)]
|
|
339
|
+
return self._status_line(f"{label}…", elapsed)
|
|
340
|
+
|
|
341
|
+
def _stop_status(self) -> None:
|
|
342
|
+
live = self._live
|
|
343
|
+
self._live = None
|
|
344
|
+
if live is not None:
|
|
345
|
+
try:
|
|
346
|
+
live.stop()
|
|
347
|
+
except Exception:
|
|
348
|
+
pass
|
|
349
|
+
|
|
350
|
+
def _stop_answer(self) -> None:
|
|
351
|
+
live = self._answer_live
|
|
352
|
+
self._answer_live = None
|
|
353
|
+
if live is not None:
|
|
354
|
+
try:
|
|
355
|
+
live.stop()
|
|
356
|
+
except Exception:
|
|
357
|
+
pass
|
|
358
|
+
self._answer_target = ""
|
|
359
|
+
self._answer_visible = ""
|
|
360
|
+
|
|
361
|
+
def _stop_live(self) -> None:
|
|
362
|
+
self._stop_status()
|
|
363
|
+
self._stop_answer()
|
|
364
|
+
|
|
365
|
+
# --------------------------------------------------------- agent display
|
|
366
|
+
def turn_started(
|
|
367
|
+
self, turn: int, max_steps: int, attachment_count: int
|
|
368
|
+
) -> None:
|
|
369
|
+
self._start_status("Thinking")
|
|
370
|
+
|
|
371
|
+
def stream_started(self) -> None:
|
|
372
|
+
self._stop_status()
|
|
373
|
+
|
|
374
|
+
def ensure_line_break(self) -> None:
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
def protocol_error(self, detail: str) -> None:
|
|
378
|
+
self._stop_live()
|
|
379
|
+
if self.quiet:
|
|
380
|
+
return
|
|
381
|
+
self.console.print(Text(" ↻ retrying with a corrected reply", style="yellow"))
|
|
382
|
+
|
|
383
|
+
def thought(self, text: str) -> None:
|
|
384
|
+
self._stop_status()
|
|
385
|
+
if self.quiet:
|
|
386
|
+
return
|
|
387
|
+
if text:
|
|
388
|
+
self.console.print(Text(f" thought: {text}", style="dim italic"))
|
|
389
|
+
|
|
390
|
+
def tool_started(self, call: ToolCall) -> None:
|
|
391
|
+
progressive = _TOOL_NAMES.get(call.name, (call.name, call.name))[0]
|
|
392
|
+
target = _tool_target(call.name, call.arguments)
|
|
393
|
+
self._start_status(f"{progressive} {target}".strip())
|
|
394
|
+
|
|
395
|
+
def tool_finished(self, result: ToolResult, elapsed_sec: float) -> None:
|
|
396
|
+
self._stop_status()
|
|
397
|
+
if self.quiet:
|
|
398
|
+
return
|
|
399
|
+
ok = result.ok
|
|
400
|
+
style = "green" if ok else "red"
|
|
401
|
+
line = Text()
|
|
402
|
+
line.append(" ⏺ ", style="dim")
|
|
403
|
+
line.append(_tool_title(result.name), style=f"bold {'cyan' if ok else 'red'}")
|
|
404
|
+
target = _tool_target(result.name, result.arguments)
|
|
405
|
+
if target:
|
|
406
|
+
line.append(f"({target})")
|
|
407
|
+
line.append(" ")
|
|
408
|
+
line.append("✓" if ok else "✗", style=f"bold {style}")
|
|
409
|
+
line.append(f" {elapsed_sec:.1f}s", style="dim")
|
|
410
|
+
self.console.print(line)
|
|
411
|
+
if not ok:
|
|
412
|
+
for raw in (result.output or "").strip().splitlines()[:3]:
|
|
413
|
+
self.console.print(Text(" " + _shorten(raw, 160), style="red"))
|
|
414
|
+
|
|
415
|
+
def wrapup_started(self) -> None:
|
|
416
|
+
self._start_status("Wrapping up")
|
|
417
|
+
|
|
418
|
+
def budget_exhausted(self) -> None:
|
|
419
|
+
self._start_status("Wrapping up")
|
|
420
|
+
|
|
421
|
+
def finished(
|
|
422
|
+
self, changed: list[str], turns: int, *, show_status: bool
|
|
423
|
+
) -> None:
|
|
424
|
+
self._stop_status()
|
|
425
|
+
|
|
426
|
+
def complete(self, outcome: "AgentOutcome", elapsed_sec: float) -> None:
|
|
427
|
+
self._stop_live()
|
|
428
|
+
final = outcome.final or "Done."
|
|
429
|
+
try:
|
|
430
|
+
self.console.print(Markdown(final))
|
|
431
|
+
except Exception:
|
|
432
|
+
self.console.print(Text(final))
|
|
433
|
+
if self.quiet:
|
|
434
|
+
return
|
|
435
|
+
steps = f"{outcome.turns} step" + ("" if outcome.turns == 1 else "s")
|
|
436
|
+
parts = [f"{elapsed_sec:.1f}s", steps]
|
|
437
|
+
if outcome.changed:
|
|
438
|
+
files = len(outcome.changed)
|
|
439
|
+
parts.append(f"{files} file" + ("" if files == 1 else "s") + " changed")
|
|
440
|
+
if outcome.limit_reached:
|
|
441
|
+
parts.append("budget reached")
|
|
442
|
+
if outcome.denied:
|
|
443
|
+
parts.append("action denied")
|
|
444
|
+
self.console.print(Text(" ✻ Done in " + " · ".join(parts), style="dim"))
|
|
445
|
+
if outcome.changed:
|
|
446
|
+
shown = outcome.changed[:6]
|
|
447
|
+
more = (
|
|
448
|
+
f" (+{len(outcome.changed) - 6} more)"
|
|
449
|
+
if len(outcome.changed) > 6
|
|
450
|
+
else ""
|
|
451
|
+
)
|
|
452
|
+
self.console.print(
|
|
453
|
+
Text(" changed: " + ", ".join(shown) + more, style="dim")
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def answer_snapshot(self, text: str) -> None:
|
|
457
|
+
if not text:
|
|
458
|
+
return
|
|
459
|
+
if self._answer_live is None:
|
|
460
|
+
self._start_answer(text)
|
|
461
|
+
return
|
|
462
|
+
self._answer_target = text
|
|
463
|
+
|
|
464
|
+
def _start_answer(self, text: str) -> None:
|
|
465
|
+
self._stop_status()
|
|
466
|
+
self._answer_target = text
|
|
467
|
+
self._answer_visible = ""
|
|
468
|
+
self._answer_started = self._clock()
|
|
469
|
+
self._answer_tick = self._answer_started
|
|
470
|
+
live = Live(
|
|
471
|
+
get_renderable=self._render_answer,
|
|
472
|
+
console=self.console,
|
|
473
|
+
transient=True,
|
|
474
|
+
refresh_per_second=15,
|
|
475
|
+
vertical_overflow="ellipsis",
|
|
476
|
+
redirect_stdout=False,
|
|
477
|
+
redirect_stderr=False,
|
|
478
|
+
)
|
|
479
|
+
try:
|
|
480
|
+
live.start(refresh=True)
|
|
481
|
+
except Exception:
|
|
482
|
+
return
|
|
483
|
+
self._answer_live = live
|
|
484
|
+
|
|
485
|
+
def _render_answer(self):
|
|
486
|
+
now = self._clock()
|
|
487
|
+
target = self._answer_target
|
|
488
|
+
if not target.startswith(self._answer_visible):
|
|
489
|
+
self._answer_visible = ""
|
|
490
|
+
dt = max(0.0, now - self._answer_tick)
|
|
491
|
+
self._answer_tick = now
|
|
492
|
+
gap = len(target) - len(self._answer_visible)
|
|
493
|
+
if gap > 0:
|
|
494
|
+
# Reveal a little faster when a large chunk arrived at once so the
|
|
495
|
+
# text flows instead of jumping a paragraph at a time.
|
|
496
|
+
speed = 180 + min(gap, 1200) * 0.8
|
|
497
|
+
step = max(1, int(speed * max(dt, 0.05)))
|
|
498
|
+
self._answer_visible = target[: len(self._answer_visible) + step]
|
|
499
|
+
elapsed = max(0.0, now - self._answer_started)
|
|
500
|
+
header = self._status_line(
|
|
501
|
+
"Responding…", elapsed, f"{len(self._answer_visible)} chars"
|
|
502
|
+
)
|
|
503
|
+
shown = self._answer_visible
|
|
504
|
+
lines = shown.splitlines()
|
|
505
|
+
# Keep the newest lines in view while the answer grows past the screen.
|
|
506
|
+
if len(lines) > 24:
|
|
507
|
+
shown = "…\n" + "\n".join(lines[-24:])
|
|
508
|
+
try:
|
|
509
|
+
body = Markdown(shown) if shown else Text("")
|
|
510
|
+
except Exception:
|
|
511
|
+
body = Text(shown)
|
|
512
|
+
return Group(header, body)
|
|
513
|
+
|
|
514
|
+
# -------------------------------------------------------------- approval
|
|
515
|
+
def confirm(self, title: str, detail: str | None) -> bool:
|
|
516
|
+
self._stop_live()
|
|
517
|
+
self.console.print()
|
|
518
|
+
self.console.print(Text(title, style="bold yellow"))
|
|
519
|
+
if detail:
|
|
520
|
+
self._print_detail(detail)
|
|
521
|
+
approved = self._auto
|
|
522
|
+
if approved:
|
|
523
|
+
self.console.print(
|
|
524
|
+
Text(" auto-approved (-y or all)", style="dim green")
|
|
525
|
+
)
|
|
526
|
+
else:
|
|
527
|
+
try:
|
|
528
|
+
# Same running-loop constraint as prompt(); approvals happen
|
|
529
|
+
# while Playwright's loop is still active.
|
|
530
|
+
answer = (
|
|
531
|
+
self._approval_session.prompt(
|
|
532
|
+
"Approve? [y]es / [n]o / [a]ll / [q]uit ❯ ",
|
|
533
|
+
in_thread=True,
|
|
534
|
+
)
|
|
535
|
+
or ""
|
|
536
|
+
).strip().lower()
|
|
537
|
+
except EOFError:
|
|
538
|
+
answer = "n"
|
|
539
|
+
if answer in {"q", "quit"}:
|
|
540
|
+
raise TuiQuit
|
|
541
|
+
if answer in {"a", "all"}:
|
|
542
|
+
self._auto = True
|
|
543
|
+
approved = True
|
|
544
|
+
elif answer in {"y", "yes"}:
|
|
545
|
+
approved = True
|
|
546
|
+
self._resume_status()
|
|
547
|
+
return approved
|
|
548
|
+
|
|
549
|
+
def _resume_status(self) -> None:
|
|
550
|
+
if not self.quiet and self._last_status_text:
|
|
551
|
+
self._start_status(self._last_status_text)
|
|
552
|
+
|
|
553
|
+
def _print_detail(self, detail: str) -> None:
|
|
554
|
+
lines = detail.rstrip("\n").splitlines()
|
|
555
|
+
looks_like_diff = detail.lstrip().startswith("---") or "\n+++" in detail[:200]
|
|
556
|
+
for line in lines[:160]:
|
|
557
|
+
if looks_like_diff:
|
|
558
|
+
if line.startswith(("+++", "---")):
|
|
559
|
+
style = "bold"
|
|
560
|
+
elif line.startswith("+"):
|
|
561
|
+
style = "green"
|
|
562
|
+
elif line.startswith("-"):
|
|
563
|
+
style = "red"
|
|
564
|
+
elif line.startswith("@@"):
|
|
565
|
+
style = "cyan"
|
|
566
|
+
else:
|
|
567
|
+
style = ""
|
|
568
|
+
else:
|
|
569
|
+
style = "yellow"
|
|
570
|
+
self.console.print(Text(" " + line, style=style))
|
|
571
|
+
if len(lines) > 160:
|
|
572
|
+
self.console.print(
|
|
573
|
+
Text(f" ... [{len(lines) - 160} diff lines omitted] ...", style="dim")
|
|
574
|
+
)
|
dscli/ui.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
_COLOR = (
|
|
7
|
+
os.environ.get("NO_COLOR") is None
|
|
8
|
+
and sys.stderr.isatty()
|
|
9
|
+
and os.environ.get("TERM") != "dumb"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
if os.name == "nt" and _COLOR:
|
|
13
|
+
try:
|
|
14
|
+
import ctypes
|
|
15
|
+
|
|
16
|
+
kernel32 = ctypes.windll.kernel32
|
|
17
|
+
kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
|
|
18
|
+
except Exception:
|
|
19
|
+
_COLOR = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def style(text: str, color: str) -> str:
|
|
23
|
+
if not _COLOR:
|
|
24
|
+
return text
|
|
25
|
+
codes = {
|
|
26
|
+
"dim": "2",
|
|
27
|
+
"red": "31",
|
|
28
|
+
"green": "32",
|
|
29
|
+
"yellow": "33",
|
|
30
|
+
"blue": "34",
|
|
31
|
+
"magenta": "35",
|
|
32
|
+
"cyan": "36",
|
|
33
|
+
"bold": "1",
|
|
34
|
+
}
|
|
35
|
+
return f"\033[{codes.get(color, '0')}m{text}\033[0m"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def step(text: str) -> None:
|
|
39
|
+
print(style(text, "cyan"), file=sys.stderr, flush=True)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ok(text: str) -> None:
|
|
43
|
+
print(style(" ok ", "green") + " " + text, file=sys.stderr, flush=True)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def fail(text: str) -> None:
|
|
47
|
+
print(style(" fail ", "red") + " " + text, file=sys.stderr, flush=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def info(text: str) -> None:
|
|
51
|
+
print(text, file=sys.stderr, flush=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def warn(text: str) -> None:
|
|
55
|
+
print(style("warn", "yellow") + " " + text, file=sys.stderr, flush=True)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def print_diff(diff_text: str, max_lines: int = 160) -> None:
|
|
59
|
+
lines = diff_text.splitlines()
|
|
60
|
+
for line in lines[:max_lines]:
|
|
61
|
+
if line.startswith("+++") or line.startswith("---"):
|
|
62
|
+
print(style(line, "bold"), file=sys.stderr)
|
|
63
|
+
elif line.startswith("+"):
|
|
64
|
+
print(style(line, "green"), file=sys.stderr)
|
|
65
|
+
elif line.startswith("-"):
|
|
66
|
+
print(style(line, "red"), file=sys.stderr)
|
|
67
|
+
elif line.startswith("@@"):
|
|
68
|
+
print(style(line, "cyan"), file=sys.stderr)
|
|
69
|
+
else:
|
|
70
|
+
print(line, file=sys.stderr)
|
|
71
|
+
if len(lines) > max_lines:
|
|
72
|
+
print(style(f"... diff truncated ({len(lines) - max_lines} more lines)", "dim"), file=sys.stderr)
|