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