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.
Files changed (40) hide show
  1. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/PKG-INFO +6 -5
  2. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/README.md +5 -4
  3. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/__init__.py +1 -1
  4. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/cli.py +11 -2
  5. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/tui.py +143 -2
  6. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli_agent.egg-info/PKG-INFO +6 -5
  7. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_cli.py +85 -6
  8. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_tui.py +82 -3
  9. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/LICENSE +0 -0
  10. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/__main__.py +0 -0
  11. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/agent.py +0 -0
  12. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/attachments.py +0 -0
  13. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/branding.py +0 -0
  14. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/browser.py +0 -0
  15. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/compaction.py +0 -0
  16. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/config.py +0 -0
  17. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/display.py +0 -0
  18. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/prompts.py +0 -0
  19. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/protocol.py +0 -0
  20. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/providers.py +0 -0
  21. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/scraper.py +0 -0
  22. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/tokens.py +0 -0
  23. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/tools.py +0 -0
  24. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli/ui.py +0 -0
  25. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli_agent.egg-info/SOURCES.txt +0 -0
  26. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli_agent.egg-info/dependency_links.txt +0 -0
  27. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli_agent.egg-info/entry_points.txt +0 -0
  28. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli_agent.egg-info/requires.txt +0 -0
  29. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/dscli_agent.egg-info/top_level.txt +0 -0
  30. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/pyproject.toml +0 -0
  31. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/setup.cfg +0 -0
  32. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_agent.py +0 -0
  33. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_branding.py +0 -0
  34. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_browser.py +0 -0
  35. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_compaction.py +0 -0
  36. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_config.py +0 -0
  37. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_display.py +0 -0
  38. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_protocol.py +0 -0
  39. {dscli_agent-0.6.0 → dscli_agent-0.7.0}/tests/test_tokens.py +0 -0
  40. {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.6.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
- | `Ctrl+C` | While the agent is working: interrupt the current turn, keep the browser session, and return to the prompt. At the prompt: clear the input. |
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 150). |
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 find
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
- | `Ctrl+C` | While the agent is working: interrupt the current turn, keep the browser session, and return to the prompt. At the prompt: clear the input. |
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 150). |
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 find
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,
@@ -1,3 +1,3 @@
1
1
  """dscli - a local coding agent for your terminal."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.7.0"
@@ -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, 150)
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 = run_agent(
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, or clear the input")
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.6.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
- | `Ctrl+C` | While the agent is working: interrupt the current turn, keep the browser session, and return to the prompt. At the prompt: clear the input. |
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 150). |
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 find
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 test_max_steps_defaults_to_20_and_is_capped_at_150(self) -> None:
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", "150"])
289
+ Config(), parser.parse_args(["run", "task", "--max-steps", "2000"])
262
290
  )
263
- self.assertEqual(cfg.max_steps, 150)
291
+ self.assertEqual(cfg.max_steps, 2000)
264
292
  cfg = _apply_run_config(
265
- Config(), parser.parse_args(["run", "task", "--max-steps", "500"])
293
+ Config(), parser.parse_args(["run", "task", "--max-steps", "5000"])
266
294
  )
267
- self.assertEqual(cfg.max_steps, 150)
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