dscli-agent 0.6.0__tar.gz → 0.7.0__tar.gz
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_agent-0.6.0 → dscli_agent-0.7.0}/PKG-INFO +6 -5
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/README.md +5 -4
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/__init__.py +1 -1
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/cli.py +11 -2
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/tui.py +143 -2
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli_agent.egg-info/PKG-INFO +6 -5
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_cli.py +85 -6
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_tui.py +82 -3
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/LICENSE +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/__main__.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/agent.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/attachments.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/branding.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/browser.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/compaction.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/config.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/display.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/prompts.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/protocol.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/providers.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/scraper.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/tokens.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/tools.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/ui.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli_agent.egg-info/SOURCES.txt +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli_agent.egg-info/dependency_links.txt +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli_agent.egg-info/entry_points.txt +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli_agent.egg-info/requires.txt +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli_agent.egg-info/top_level.txt +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/pyproject.toml +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/setup.cfg +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_agent.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_branding.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_browser.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_compaction.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_config.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_display.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_protocol.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_tokens.py +0 -0
- {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dscli-agent
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: A local coding agent that uses DeepSeek web chat as its model
|
|
5
5
|
Author: Anish
|
|
6
6
|
License-Expression: MIT
|
|
@@ -232,7 +232,8 @@ every run with `dscli config --set deeplanning=true`, or turn it off for one run
|
|
|
232
232
|
| --- | --- |
|
|
233
233
|
| `Enter` | Send the message (or accept a `/` completion). |
|
|
234
234
|
| `Esc` then `Enter`, or `Ctrl+J` | Insert a newline for multi-line input. |
|
|
235
|
-
| `
|
|
235
|
+
| `Esc` | While the agent is working: interrupt the current turn, keep the browser session, and return to the prompt. |
|
|
236
|
+
| `Ctrl+C` | While the agent is working: interrupt the current turn. At the prompt: clear the input. |
|
|
236
237
|
| `Ctrl+D` | Exit dscli. |
|
|
237
238
|
| `↑` / `↓` | Browse previous messages. |
|
|
238
239
|
| `Ctrl+O` | Expand the last thinking block or tool output in full. |
|
|
@@ -317,7 +318,7 @@ failures and unconfirmed completions are always logged to stderr, including unde
|
|
|
317
318
|
| `--headed` / `--headless` | Show or hide the browser. Headless is the default. |
|
|
318
319
|
| `--search` | Turn DeepSeek's Search mode on for the run (DeepThink is always on). |
|
|
319
320
|
| `--deeplanning` | Require a non-empty `update_plan` before writes or commands (alias: `--deep-planning`). |
|
|
320
|
-
| `--max-steps N` | Maximum model turns (default 20, cap
|
|
321
|
+
| `--max-steps N` | Maximum model turns (default 20, cap 2000). |
|
|
321
322
|
| `--timeout N` | Response timeout per turn in seconds (default 300). |
|
|
322
323
|
| `--verify CMD` | Run CMD automatically after file edits; failures block success. |
|
|
323
324
|
| `--no-verify` | Disable the configured `verify_command` for this run. |
|
|
@@ -558,8 +559,8 @@ Debug screenshots and unparsed replies are written to `%APPDATA%\dscli\debug`.
|
|
|
558
559
|
`complete: true`; tool calls still wait for the complete, validated JSON envelope.
|
|
559
560
|
The context bar is likewise a local estimate of sent and received text, not usage
|
|
560
561
|
reported by DeepSeek.
|
|
561
|
-
- Interrupting with Ctrl+C clicks DeepSeek's labelled stop button when dscli can
|
|
562
|
-
it. If the page does not expose one, generation may continue in the web chat; the
|
|
562
|
+
- Interrupting with Esc or Ctrl+C clicks DeepSeek's labelled stop button when dscli can
|
|
563
|
+
find it. If the page does not expose one, generation may continue in the web chat; the
|
|
563
564
|
interrupted answer is discarded, and the next message (or `/new`) re-establishes a
|
|
564
565
|
clean turn.
|
|
565
566
|
- Web-chat attachments accept only certain file types. When a file cannot be attached,
|
|
@@ -202,7 +202,8 @@ every run with `dscli config --set deeplanning=true`, or turn it off for one run
|
|
|
202
202
|
| --- | --- |
|
|
203
203
|
| `Enter` | Send the message (or accept a `/` completion). |
|
|
204
204
|
| `Esc` then `Enter`, or `Ctrl+J` | Insert a newline for multi-line input. |
|
|
205
|
-
| `
|
|
205
|
+
| `Esc` | While the agent is working: interrupt the current turn, keep the browser session, and return to the prompt. |
|
|
206
|
+
| `Ctrl+C` | While the agent is working: interrupt the current turn. At the prompt: clear the input. |
|
|
206
207
|
| `Ctrl+D` | Exit dscli. |
|
|
207
208
|
| `↑` / `↓` | Browse previous messages. |
|
|
208
209
|
| `Ctrl+O` | Expand the last thinking block or tool output in full. |
|
|
@@ -287,7 +288,7 @@ failures and unconfirmed completions are always logged to stderr, including unde
|
|
|
287
288
|
| `--headed` / `--headless` | Show or hide the browser. Headless is the default. |
|
|
288
289
|
| `--search` | Turn DeepSeek's Search mode on for the run (DeepThink is always on). |
|
|
289
290
|
| `--deeplanning` | Require a non-empty `update_plan` before writes or commands (alias: `--deep-planning`). |
|
|
290
|
-
| `--max-steps N` | Maximum model turns (default 20, cap
|
|
291
|
+
| `--max-steps N` | Maximum model turns (default 20, cap 2000). |
|
|
291
292
|
| `--timeout N` | Response timeout per turn in seconds (default 300). |
|
|
292
293
|
| `--verify CMD` | Run CMD automatically after file edits; failures block success. |
|
|
293
294
|
| `--no-verify` | Disable the configured `verify_command` for this run. |
|
|
@@ -528,8 +529,8 @@ Debug screenshots and unparsed replies are written to `%APPDATA%\dscli\debug`.
|
|
|
528
529
|
`complete: true`; tool calls still wait for the complete, validated JSON envelope.
|
|
529
530
|
The context bar is likewise a local estimate of sent and received text, not usage
|
|
530
531
|
reported by DeepSeek.
|
|
531
|
-
- Interrupting with Ctrl+C clicks DeepSeek's labelled stop button when dscli can
|
|
532
|
-
it. If the page does not expose one, generation may continue in the web chat; the
|
|
532
|
+
- Interrupting with Esc or Ctrl+C clicks DeepSeek's labelled stop button when dscli can
|
|
533
|
+
find it. If the page does not expose one, generation may continue in the web chat; the
|
|
533
534
|
interrupted answer is discarded, and the next message (or `/new`) re-establishes a
|
|
534
535
|
clean turn.
|
|
535
536
|
- Web-chat attachments accept only certain file types. When a file cannot be attached,
|
|
@@ -199,7 +199,7 @@ def _apply_run_config(cfg: Config, args: argparse.Namespace) -> Config:
|
|
|
199
199
|
if args.max_steps is not None:
|
|
200
200
|
if args.max_steps < 1:
|
|
201
201
|
raise ValueError("--max-steps must be at least 1.")
|
|
202
|
-
cfg.max_steps = min(args.max_steps,
|
|
202
|
+
cfg.max_steps = min(args.max_steps, 2000)
|
|
203
203
|
if args.search is not None:
|
|
204
204
|
cfg.search = args.search
|
|
205
205
|
if args.deeplanning is not None:
|
|
@@ -641,6 +641,14 @@ def _chat_loop_plain(
|
|
|
641
641
|
first_message = True
|
|
642
642
|
|
|
643
643
|
|
|
644
|
+
def _run_agent_interruptible(tui, **kwargs):
|
|
645
|
+
tui.start_interrupt_watch()
|
|
646
|
+
try:
|
|
647
|
+
return run_agent(**kwargs)
|
|
648
|
+
finally:
|
|
649
|
+
tui.stop_interrupt_watch()
|
|
650
|
+
|
|
651
|
+
|
|
644
652
|
def _chat_loop_tui(
|
|
645
653
|
session: ChatSession,
|
|
646
654
|
toolbox: Toolbox,
|
|
@@ -700,7 +708,8 @@ def _chat_loop_tui(
|
|
|
700
708
|
pending_resume = None
|
|
701
709
|
started = time.monotonic()
|
|
702
710
|
try:
|
|
703
|
-
outcome =
|
|
711
|
+
outcome = _run_agent_interruptible(
|
|
712
|
+
tui,
|
|
704
713
|
session=session,
|
|
705
714
|
config=cfg,
|
|
706
715
|
toolbox=toolbox,
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import _thread
|
|
3
4
|
import os
|
|
4
5
|
import re
|
|
6
|
+
import sys
|
|
5
7
|
import textwrap
|
|
8
|
+
import threading
|
|
6
9
|
import time
|
|
7
10
|
from pathlib import Path
|
|
8
11
|
from typing import TYPE_CHECKING, Callable
|
|
@@ -140,6 +143,114 @@ class TuiQuit(KeyboardInterrupt):
|
|
|
140
143
|
"""The user chose q at an approval prompt; exit instead of continuing."""
|
|
141
144
|
|
|
142
145
|
|
|
146
|
+
def _read_key_windows() -> str | None:
|
|
147
|
+
import msvcrt
|
|
148
|
+
|
|
149
|
+
if not msvcrt.kbhit():
|
|
150
|
+
return None
|
|
151
|
+
return msvcrt.getwch()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _read_key_posix(fd: int) -> str | None:
|
|
155
|
+
import select
|
|
156
|
+
|
|
157
|
+
ready, _, _ = select.select([fd], [], [], 0.05)
|
|
158
|
+
if not ready:
|
|
159
|
+
return None
|
|
160
|
+
data = os.read(fd, 1)
|
|
161
|
+
if data != b"\x1b":
|
|
162
|
+
return data.decode(errors="replace")
|
|
163
|
+
# Arrow and function keys start with Esc, so wait a beat: a lone Esc
|
|
164
|
+
# interrupts, while a full escape sequence is discarded.
|
|
165
|
+
ready, _, _ = select.select([fd], [], [], 0.02)
|
|
166
|
+
if not ready:
|
|
167
|
+
return "\x1b"
|
|
168
|
+
sequence = bytearray(os.read(fd, 32))
|
|
169
|
+
while True:
|
|
170
|
+
ready, _, _ = select.select([fd], [], [], 0.005)
|
|
171
|
+
if not ready:
|
|
172
|
+
break
|
|
173
|
+
sequence += os.read(fd, 32)
|
|
174
|
+
return "\x1b" + sequence.decode(errors="replace")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class _EscapeKeyWatcher:
|
|
178
|
+
"""Watch the terminal for Esc while the agent runs.
|
|
179
|
+
|
|
180
|
+
prompt_toolkit only owns stdin while the prompt is active, so between
|
|
181
|
+
messages this watcher takes the terminal over in cbreak mode and turns
|
|
182
|
+
Esc into an interruption. Every other keypress is discarded. It stays
|
|
183
|
+
inert when stdin is not a terminal.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(
|
|
187
|
+
self,
|
|
188
|
+
on_escape: Callable[[], None],
|
|
189
|
+
*,
|
|
190
|
+
read_key: Callable[[], str | None] | None = None,
|
|
191
|
+
) -> None:
|
|
192
|
+
self._on_escape = on_escape
|
|
193
|
+
self._read_key = read_key
|
|
194
|
+
self._thread: threading.Thread | None = None
|
|
195
|
+
self._stop = threading.Event()
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def running(self) -> bool:
|
|
199
|
+
return self._thread is not None
|
|
200
|
+
|
|
201
|
+
def start(self) -> None:
|
|
202
|
+
if self._thread is not None:
|
|
203
|
+
return
|
|
204
|
+
if self._read_key is None:
|
|
205
|
+
stream = sys.stdin
|
|
206
|
+
if stream is None or not stream.isatty():
|
|
207
|
+
return
|
|
208
|
+
self._stop.clear()
|
|
209
|
+
self._thread = threading.Thread(
|
|
210
|
+
target=self._run, name="dscli-esc-watch", daemon=True
|
|
211
|
+
)
|
|
212
|
+
self._thread.start()
|
|
213
|
+
|
|
214
|
+
def stop(self) -> None:
|
|
215
|
+
self._stop.set()
|
|
216
|
+
thread = self._thread
|
|
217
|
+
self._thread = None
|
|
218
|
+
if thread is not None and thread is not threading.current_thread():
|
|
219
|
+
thread.join(timeout=1.0)
|
|
220
|
+
|
|
221
|
+
def _run(self) -> None:
|
|
222
|
+
try:
|
|
223
|
+
if self._read_key is not None:
|
|
224
|
+
self._loop(self._read_key)
|
|
225
|
+
elif os.name == "nt":
|
|
226
|
+
self._loop(_read_key_windows)
|
|
227
|
+
else:
|
|
228
|
+
self._loop_posix()
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
def _loop_posix(self) -> None:
|
|
233
|
+
import termios
|
|
234
|
+
import tty
|
|
235
|
+
|
|
236
|
+
fd = sys.stdin.fileno()
|
|
237
|
+
previous = termios.tcgetattr(fd)
|
|
238
|
+
try:
|
|
239
|
+
tty.setcbreak(fd, termios.TCSANOW)
|
|
240
|
+
self._loop(lambda: _read_key_posix(fd))
|
|
241
|
+
finally:
|
|
242
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, previous)
|
|
243
|
+
|
|
244
|
+
def _loop(self, read_key: Callable[[], str | None]) -> None:
|
|
245
|
+
while not self._stop.is_set():
|
|
246
|
+
key = read_key()
|
|
247
|
+
if key == "\x1b":
|
|
248
|
+
self._on_escape()
|
|
249
|
+
return
|
|
250
|
+
if key is None:
|
|
251
|
+
self._stop.wait(0.05)
|
|
252
|
+
|
|
253
|
+
|
|
143
254
|
class SlashCompleter(Completer):
|
|
144
255
|
def get_completions(self, document, complete_event):
|
|
145
256
|
text = document.text_before_cursor
|
|
@@ -332,6 +443,7 @@ class Tui:
|
|
|
332
443
|
console: Console | None = None,
|
|
333
444
|
prompt_session: PromptSession | None = None,
|
|
334
445
|
approval_session: PromptSession | None = None,
|
|
446
|
+
interrupt_watcher: _EscapeKeyWatcher | None = None,
|
|
335
447
|
clock: Callable[[], float] = time.monotonic,
|
|
336
448
|
) -> None:
|
|
337
449
|
self.workspace = workspace
|
|
@@ -369,6 +481,9 @@ class Tui:
|
|
|
369
481
|
self._plan_snapshot: list[tuple[str, str]] = []
|
|
370
482
|
self._prompt_session = prompt_session or self._build_prompt_session()
|
|
371
483
|
self._approval_session = approval_session or self._build_approval_session()
|
|
484
|
+
self._interrupt_watcher = interrupt_watcher or _EscapeKeyWatcher(
|
|
485
|
+
self._interrupt_current_run
|
|
486
|
+
)
|
|
372
487
|
|
|
373
488
|
# ---------------------------------------------------------------- prompt
|
|
374
489
|
def _history(self):
|
|
@@ -459,6 +574,18 @@ class Tui:
|
|
|
459
574
|
[("class:prompt", "❯ ")], in_thread=True
|
|
460
575
|
)
|
|
461
576
|
|
|
577
|
+
# ------------------------------------------------------- interrupt by Esc
|
|
578
|
+
def start_interrupt_watch(self) -> None:
|
|
579
|
+
self._interrupt_watcher.start()
|
|
580
|
+
|
|
581
|
+
def stop_interrupt_watch(self) -> None:
|
|
582
|
+
self._interrupt_watcher.stop()
|
|
583
|
+
|
|
584
|
+
def _interrupt_current_run(self) -> None:
|
|
585
|
+
# Take the same path as Ctrl+C: unwind the run in the main thread,
|
|
586
|
+
# where the chat loop stops generation and keeps the session alive.
|
|
587
|
+
_thread.interrupt_main()
|
|
588
|
+
|
|
462
589
|
# -------------------------------------------------------------- lifecycle
|
|
463
590
|
def banner(self, cfg: Config) -> None:
|
|
464
591
|
self._stop_live()
|
|
@@ -585,7 +712,8 @@ class Tui:
|
|
|
585
712
|
keys.add_column()
|
|
586
713
|
keys.add_row("Enter", "send the message")
|
|
587
714
|
keys.add_row("Esc+Enter / Ctrl+J", "insert a newline")
|
|
588
|
-
keys.add_row("Ctrl+C", "interrupt while working
|
|
715
|
+
keys.add_row("Esc / Ctrl+C", "interrupt the response while working")
|
|
716
|
+
keys.add_row("Ctrl+C", "clear the input at the prompt")
|
|
589
717
|
keys.add_row("Ctrl+O", "expand the last thinking block or tool output")
|
|
590
718
|
keys.add_row("Ctrl+D", "exit dscli")
|
|
591
719
|
self.console.print(keys)
|
|
@@ -595,6 +723,7 @@ class Tui:
|
|
|
595
723
|
self.console.print(Text(message, style="dim"))
|
|
596
724
|
|
|
597
725
|
def close(self) -> None:
|
|
726
|
+
self._interrupt_watcher.stop()
|
|
598
727
|
self._stop_live()
|
|
599
728
|
|
|
600
729
|
def interrupted(self) -> None:
|
|
@@ -716,7 +845,7 @@ class Tui:
|
|
|
716
845
|
line.append(f" {elapsed:.0f}s", style="dim")
|
|
717
846
|
if detail:
|
|
718
847
|
line.append(f" · {detail}", style="dim")
|
|
719
|
-
line.append(" · Ctrl+C to interrupt", style="dim")
|
|
848
|
+
line.append(" · Esc / Ctrl+C to interrupt", style="dim")
|
|
720
849
|
return line
|
|
721
850
|
|
|
722
851
|
def _render_status(self):
|
|
@@ -1265,6 +1394,18 @@ class Tui:
|
|
|
1265
1394
|
|
|
1266
1395
|
# -------------------------------------------------------------- approval
|
|
1267
1396
|
def confirm(self, title: str, detail: str | None) -> bool:
|
|
1397
|
+
# The approval prompt owns stdin; a parallel Esc watcher would eat
|
|
1398
|
+
# the answer keys, so pause it while the question is on screen.
|
|
1399
|
+
resume_watch = self._interrupt_watcher.running
|
|
1400
|
+
if resume_watch:
|
|
1401
|
+
self._interrupt_watcher.stop()
|
|
1402
|
+
try:
|
|
1403
|
+
return self._confirm(title, detail)
|
|
1404
|
+
finally:
|
|
1405
|
+
if resume_watch:
|
|
1406
|
+
self._interrupt_watcher.start()
|
|
1407
|
+
|
|
1408
|
+
def _confirm(self, title: str, detail: str | None) -> bool:
|
|
1268
1409
|
self._settle()
|
|
1269
1410
|
self.console.print()
|
|
1270
1411
|
self.console.print(Text(title, style=f"bold {branding.BROWN}"))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dscli-agent
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: A local coding agent that uses DeepSeek web chat as its model
|
|
5
5
|
Author: Anish
|
|
6
6
|
License-Expression: MIT
|
|
@@ -232,7 +232,8 @@ every run with `dscli config --set deeplanning=true`, or turn it off for one run
|
|
|
232
232
|
| --- | --- |
|
|
233
233
|
| `Enter` | Send the message (or accept a `/` completion). |
|
|
234
234
|
| `Esc` then `Enter`, or `Ctrl+J` | Insert a newline for multi-line input. |
|
|
235
|
-
| `
|
|
235
|
+
| `Esc` | While the agent is working: interrupt the current turn, keep the browser session, and return to the prompt. |
|
|
236
|
+
| `Ctrl+C` | While the agent is working: interrupt the current turn. At the prompt: clear the input. |
|
|
236
237
|
| `Ctrl+D` | Exit dscli. |
|
|
237
238
|
| `↑` / `↓` | Browse previous messages. |
|
|
238
239
|
| `Ctrl+O` | Expand the last thinking block or tool output in full. |
|
|
@@ -317,7 +318,7 @@ failures and unconfirmed completions are always logged to stderr, including unde
|
|
|
317
318
|
| `--headed` / `--headless` | Show or hide the browser. Headless is the default. |
|
|
318
319
|
| `--search` | Turn DeepSeek's Search mode on for the run (DeepThink is always on). |
|
|
319
320
|
| `--deeplanning` | Require a non-empty `update_plan` before writes or commands (alias: `--deep-planning`). |
|
|
320
|
-
| `--max-steps N` | Maximum model turns (default 20, cap
|
|
321
|
+
| `--max-steps N` | Maximum model turns (default 20, cap 2000). |
|
|
321
322
|
| `--timeout N` | Response timeout per turn in seconds (default 300). |
|
|
322
323
|
| `--verify CMD` | Run CMD automatically after file edits; failures block success. |
|
|
323
324
|
| `--no-verify` | Disable the configured `verify_command` for this run. |
|
|
@@ -558,8 +559,8 @@ Debug screenshots and unparsed replies are written to `%APPDATA%\dscli\debug`.
|
|
|
558
559
|
`complete: true`; tool calls still wait for the complete, validated JSON envelope.
|
|
559
560
|
The context bar is likewise a local estimate of sent and received text, not usage
|
|
560
561
|
reported by DeepSeek.
|
|
561
|
-
- Interrupting with Ctrl+C clicks DeepSeek's labelled stop button when dscli can
|
|
562
|
-
it. If the page does not expose one, generation may continue in the web chat; the
|
|
562
|
+
- Interrupting with Esc or Ctrl+C clicks DeepSeek's labelled stop button when dscli can
|
|
563
|
+
find it. If the page does not expose one, generation may continue in the web chat; the
|
|
563
564
|
interrupted answer is discarded, and the next message (or `/new`) re-establishes a
|
|
564
565
|
clean turn.
|
|
565
566
|
- Web-chat attachments accept only certain file types. When a file cannot be attached,
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import _thread
|
|
3
4
|
import io
|
|
4
5
|
import os
|
|
5
6
|
import tempfile
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
6
9
|
import unittest
|
|
7
10
|
from contextlib import redirect_stderr, redirect_stdout
|
|
8
11
|
from pathlib import Path
|
|
@@ -28,7 +31,7 @@ from dscli.cli import (
|
|
|
28
31
|
)
|
|
29
32
|
from dscli.config import Config
|
|
30
33
|
from dscli.protocol import ProtocolError
|
|
31
|
-
from dscli.tui import TuiQuit
|
|
34
|
+
from dscli.tui import Tui, TuiQuit, _EscapeKeyWatcher
|
|
32
35
|
|
|
33
36
|
|
|
34
37
|
class FakeSession:
|
|
@@ -72,6 +75,18 @@ class FakeSession:
|
|
|
72
75
|
return self.resume_result
|
|
73
76
|
|
|
74
77
|
|
|
78
|
+
class StubPromptSession:
|
|
79
|
+
def __init__(self, answers: list[str]) -> None:
|
|
80
|
+
self.answers = list(answers)
|
|
81
|
+
self.calls: list[dict] = []
|
|
82
|
+
|
|
83
|
+
def prompt(self, message="", **kwargs) -> str:
|
|
84
|
+
self.calls.append(kwargs)
|
|
85
|
+
if not self.answers:
|
|
86
|
+
raise AssertionError("unexpected prompt")
|
|
87
|
+
return self.answers.pop(0)
|
|
88
|
+
|
|
89
|
+
|
|
75
90
|
class FakeTui:
|
|
76
91
|
def __init__(self, inputs) -> None:
|
|
77
92
|
self.inputs = iter(inputs)
|
|
@@ -84,6 +99,10 @@ class FakeTui:
|
|
|
84
99
|
self.statuses = []
|
|
85
100
|
self.usage: list[int] = []
|
|
86
101
|
self.events: list[str] | None = None
|
|
102
|
+
self.watch_starts = 0
|
|
103
|
+
self.watch_stops = 0
|
|
104
|
+
self.watching = False
|
|
105
|
+
self.watching_at_interrupt: bool | None = None
|
|
87
106
|
|
|
88
107
|
def prompt(self) -> str:
|
|
89
108
|
value = next(self.inputs)
|
|
@@ -140,6 +159,15 @@ class FakeTui:
|
|
|
140
159
|
|
|
141
160
|
def interrupted(self) -> None:
|
|
142
161
|
self.interrupts += 1
|
|
162
|
+
self.watching_at_interrupt = self.watching
|
|
163
|
+
|
|
164
|
+
def start_interrupt_watch(self) -> None:
|
|
165
|
+
self.watch_starts += 1
|
|
166
|
+
self.watching = True
|
|
167
|
+
|
|
168
|
+
def stop_interrupt_watch(self) -> None:
|
|
169
|
+
self.watch_stops += 1
|
|
170
|
+
self.watching = False
|
|
143
171
|
|
|
144
172
|
def close(self) -> None:
|
|
145
173
|
self.closed += 1
|
|
@@ -254,17 +282,17 @@ class InteractiveChatTests(unittest.TestCase):
|
|
|
254
282
|
)
|
|
255
283
|
self.assertTrue(cfg.shell_enabled)
|
|
256
284
|
|
|
257
|
-
def
|
|
285
|
+
def test_max_steps_defaults_to_20_and_is_capped_at_2000(self) -> None:
|
|
258
286
|
parser = build_parser()
|
|
259
287
|
self.assertEqual(Config().max_steps, 20)
|
|
260
288
|
cfg = _apply_run_config(
|
|
261
|
-
Config(), parser.parse_args(["run", "task", "--max-steps", "
|
|
289
|
+
Config(), parser.parse_args(["run", "task", "--max-steps", "2000"])
|
|
262
290
|
)
|
|
263
|
-
self.assertEqual(cfg.max_steps,
|
|
291
|
+
self.assertEqual(cfg.max_steps, 2000)
|
|
264
292
|
cfg = _apply_run_config(
|
|
265
|
-
Config(), parser.parse_args(["run", "task", "--max-steps", "
|
|
293
|
+
Config(), parser.parse_args(["run", "task", "--max-steps", "5000"])
|
|
266
294
|
)
|
|
267
|
-
self.assertEqual(cfg.max_steps,
|
|
295
|
+
self.assertEqual(cfg.max_steps, 2000)
|
|
268
296
|
|
|
269
297
|
def test_should_use_tui_respects_flags_and_environment(self) -> None:
|
|
270
298
|
plain = build_parser().parse_args(["chat", "--plain"])
|
|
@@ -353,12 +381,63 @@ class InteractiveChatTests(unittest.TestCase):
|
|
|
353
381
|
self.assertEqual(result, 0)
|
|
354
382
|
self.assertEqual(session.stopped, 1)
|
|
355
383
|
self.assertEqual(fake_tui.interrupts, 1)
|
|
384
|
+
self.assertFalse(fake_tui.watching_at_interrupt)
|
|
385
|
+
self.assertEqual(fake_tui.watch_starts, 2)
|
|
386
|
+
self.assertEqual(fake_tui.watch_stops, 2)
|
|
356
387
|
self.assertEqual(len(fake_tui.completed), 1)
|
|
357
388
|
self.assertEqual(fake_tui.completed[0][0].final, "recovered")
|
|
358
389
|
self.assertEqual(session.closed, 1)
|
|
359
390
|
calls = run_agent.call_args_list
|
|
360
391
|
self.assertEqual([call.kwargs["followup"] for call in calls], [False, True])
|
|
361
392
|
|
|
393
|
+
def test_escape_interrupts_a_running_turn(self) -> None:
|
|
394
|
+
args = build_parser().parse_args(["chat", "--workspace", str(self.workspace)])
|
|
395
|
+
session = FakeSession()
|
|
396
|
+
entered = threading.Event()
|
|
397
|
+
|
|
398
|
+
def read_key() -> str | None:
|
|
399
|
+
return "\x1b" if entered.is_set() else None
|
|
400
|
+
|
|
401
|
+
def slow_run(**_kwargs):
|
|
402
|
+
entered.set()
|
|
403
|
+
deadline = time.monotonic() + 5
|
|
404
|
+
while time.monotonic() < deadline:
|
|
405
|
+
time.sleep(0.05)
|
|
406
|
+
|
|
407
|
+
def interrupt() -> None:
|
|
408
|
+
tui._interrupt_current_run()
|
|
409
|
+
|
|
410
|
+
watcher = _EscapeKeyWatcher(interrupt, read_key=read_key)
|
|
411
|
+
console = Console(file=io.StringIO(), force_terminal=False, record=True)
|
|
412
|
+
tui = Tui(
|
|
413
|
+
workspace=self.workspace,
|
|
414
|
+
console=console,
|
|
415
|
+
prompt_session=StubPromptSession(["hello", "/exit"]),
|
|
416
|
+
approval_session=StubPromptSession([]),
|
|
417
|
+
interrupt_watcher=watcher,
|
|
418
|
+
)
|
|
419
|
+
with (
|
|
420
|
+
patch("dscli.cli.ensure_config_file"),
|
|
421
|
+
patch("dscli.cli.load_config", return_value=Config()),
|
|
422
|
+
patch("dscli.cli._should_use_tui", return_value=True),
|
|
423
|
+
patch("dscli.cli._try_create_tui", return_value=tui),
|
|
424
|
+
patch("dscli.cli._new_session", return_value=session),
|
|
425
|
+
patch(
|
|
426
|
+
"dscli.cli._ensure_ready",
|
|
427
|
+
side_effect=lambda session, cfg, allow_auto_login, **_kw: session,
|
|
428
|
+
),
|
|
429
|
+
patch("dscli.cli.run_agent", side_effect=slow_run),
|
|
430
|
+
redirect_stderr(io.StringIO()),
|
|
431
|
+
):
|
|
432
|
+
started = time.monotonic()
|
|
433
|
+
result = cmd_chat(args)
|
|
434
|
+
elapsed = time.monotonic() - started
|
|
435
|
+
self.assertEqual(result, 0)
|
|
436
|
+
self.assertEqual(session.stopped, 1)
|
|
437
|
+
self.assertLess(elapsed, 3.0)
|
|
438
|
+
self.assertFalse(watcher.running)
|
|
439
|
+
self.assertIn("Interrupted", console.export_text())
|
|
440
|
+
|
|
362
441
|
def test_tui_ctrl_c_at_prompt_clears_input(self) -> None:
|
|
363
442
|
result, session, fake_tui, run_agent = self.run_tui_chat(
|
|
364
443
|
[KeyboardInterrupt(), "/exit"], []
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import _thread
|
|
3
4
|
import io
|
|
4
5
|
import time
|
|
5
6
|
import unittest
|
|
@@ -14,7 +15,7 @@ from dscli import branding
|
|
|
14
15
|
from dscli.agent import AgentOutcome
|
|
15
16
|
from dscli.config import Config
|
|
16
17
|
from dscli.protocol import ToolCall
|
|
17
|
-
from dscli.tui import SlashCompleter, Tui, TuiQuit
|
|
18
|
+
from dscli.tui import SlashCompleter, Tui, TuiQuit, _EscapeKeyWatcher
|
|
18
19
|
from dscli.tools import PlanStep, ToolResult
|
|
19
20
|
|
|
20
21
|
|
|
@@ -40,6 +41,21 @@ class FakeClock:
|
|
|
40
41
|
return self.now
|
|
41
42
|
|
|
42
43
|
|
|
44
|
+
class FakeWatcher:
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self.starts = 0
|
|
47
|
+
self.stops = 0
|
|
48
|
+
self.running = False
|
|
49
|
+
|
|
50
|
+
def start(self) -> None:
|
|
51
|
+
self.starts += 1
|
|
52
|
+
self.running = True
|
|
53
|
+
|
|
54
|
+
def stop(self) -> None:
|
|
55
|
+
self.stops += 1
|
|
56
|
+
self.running = False
|
|
57
|
+
|
|
58
|
+
|
|
43
59
|
class TuiTests(unittest.TestCase):
|
|
44
60
|
def make_tui(
|
|
45
61
|
self,
|
|
@@ -241,7 +257,7 @@ class TuiTests(unittest.TestCase):
|
|
|
241
257
|
tui.stop_status()
|
|
242
258
|
text = self.output()
|
|
243
259
|
self.assertIn("Thinking", text)
|
|
244
|
-
self.assertIn("Ctrl+C to interrupt", text)
|
|
260
|
+
self.assertIn("Esc / Ctrl+C to interrupt", text)
|
|
245
261
|
self.assertNotIn("DeepSeek", text)
|
|
246
262
|
|
|
247
263
|
def test_tool_status_shows_the_tool_and_target(self) -> None:
|
|
@@ -276,7 +292,7 @@ class TuiTests(unittest.TestCase):
|
|
|
276
292
|
self.console.print(tui._render_answer())
|
|
277
293
|
text = self.output()
|
|
278
294
|
self.assertIn("Responding", text)
|
|
279
|
-
self.assertIn("Ctrl+C to interrupt", text)
|
|
295
|
+
self.assertIn("Esc / Ctrl+C to interrupt", text)
|
|
280
296
|
self.assertIn("Hello world", text)
|
|
281
297
|
|
|
282
298
|
def test_confirm_prompts_and_returns_answers(self) -> None:
|
|
@@ -316,6 +332,69 @@ class TuiTests(unittest.TestCase):
|
|
|
316
332
|
with self.assertRaises(TuiQuit):
|
|
317
333
|
tui.confirm("Create a.py?", None)
|
|
318
334
|
|
|
335
|
+
def test_escape_watcher_fires_on_esc_and_ignores_other_keys(self) -> None:
|
|
336
|
+
pressed: list[bool] = []
|
|
337
|
+
keys = iter(["x", "\x1b[A", "\x1b"])
|
|
338
|
+
watcher = _EscapeKeyWatcher(
|
|
339
|
+
lambda: pressed.append(True), read_key=lambda: next(keys, None)
|
|
340
|
+
)
|
|
341
|
+
watcher.start()
|
|
342
|
+
self.assertTrue(watcher.running)
|
|
343
|
+
try:
|
|
344
|
+
deadline = time.monotonic() + 2
|
|
345
|
+
while not pressed and time.monotonic() < deadline:
|
|
346
|
+
time.sleep(0.01)
|
|
347
|
+
finally:
|
|
348
|
+
watcher.stop()
|
|
349
|
+
self.assertEqual(pressed, [True])
|
|
350
|
+
self.assertFalse(watcher.running)
|
|
351
|
+
|
|
352
|
+
def test_escape_interrupts_the_main_thread(self) -> None:
|
|
353
|
+
def trigger() -> None:
|
|
354
|
+
time.sleep(0.1)
|
|
355
|
+
_thread.interrupt_main()
|
|
356
|
+
|
|
357
|
+
watcher = _EscapeKeyWatcher(trigger, read_key=lambda: "\x1b")
|
|
358
|
+
watcher.start()
|
|
359
|
+
try:
|
|
360
|
+
with self.assertRaises(KeyboardInterrupt):
|
|
361
|
+
time.sleep(3)
|
|
362
|
+
finally:
|
|
363
|
+
watcher.stop()
|
|
364
|
+
|
|
365
|
+
def test_watch_methods_delegate_to_the_watcher(self) -> None:
|
|
366
|
+
fake = FakeWatcher()
|
|
367
|
+
tui = Tui(
|
|
368
|
+
workspace=Path("C:/workspace"),
|
|
369
|
+
console=Console(file=io.StringIO()),
|
|
370
|
+
prompt_session=FakePromptSession(),
|
|
371
|
+
approval_session=FakePromptSession(),
|
|
372
|
+
interrupt_watcher=fake,
|
|
373
|
+
)
|
|
374
|
+
tui.start_interrupt_watch()
|
|
375
|
+
self.assertTrue(fake.running)
|
|
376
|
+
tui.stop_interrupt_watch()
|
|
377
|
+
self.assertFalse(fake.running)
|
|
378
|
+
self.assertEqual((fake.starts, fake.stops), (1, 1))
|
|
379
|
+
tui.start_interrupt_watch()
|
|
380
|
+
tui.close()
|
|
381
|
+
self.assertFalse(fake.running)
|
|
382
|
+
self.assertEqual(fake.stops, 2)
|
|
383
|
+
|
|
384
|
+
def test_approval_pauses_and_resumes_the_escape_watcher(self) -> None:
|
|
385
|
+
fake = FakeWatcher()
|
|
386
|
+
fake.running = True
|
|
387
|
+
tui = Tui(
|
|
388
|
+
workspace=Path("C:/workspace"),
|
|
389
|
+
console=Console(file=io.StringIO()),
|
|
390
|
+
prompt_session=FakePromptSession(),
|
|
391
|
+
approval_session=FakePromptSession(["y"]),
|
|
392
|
+
interrupt_watcher=fake,
|
|
393
|
+
)
|
|
394
|
+
self.assertTrue(tui.confirm("Create a.py?", None))
|
|
395
|
+
self.assertTrue(fake.running)
|
|
396
|
+
self.assertEqual((fake.starts, fake.stops), (1, 1))
|
|
397
|
+
|
|
319
398
|
def test_slash_completer_only_completes_commands(self) -> None:
|
|
320
399
|
completer = SlashCompleter()
|
|
321
400
|
completions = list(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|