drydock-cli 3.0.27__tar.gz → 3.0.29__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 (54) hide show
  1. {drydock_cli-3.0.27/drydock_cli.egg-info → drydock_cli-3.0.29}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/__init__.py +1 -1
  3. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/agent.py +12 -0
  4. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/providers.py +93 -6
  5. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/tools/__init__.py +36 -15
  6. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/tui/app.py +56 -8
  7. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/tui/widgets.py +33 -0
  8. {drydock_cli-3.0.27 → drydock_cli-3.0.29/drydock_cli.egg-info}/PKG-INFO +1 -1
  9. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/pyproject.toml +1 -1
  10. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_stop.py +25 -0
  11. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_streaming_newlines.py +2 -2
  12. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/LICENSE +0 -0
  13. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/NOTICE +0 -0
  14. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/README.md +0 -0
  15. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/__main__.py +0 -0
  16. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/bash_safety.py +0 -0
  17. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/cli.py +0 -0
  18. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/compaction.py +0 -0
  19. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/config.py +0 -0
  20. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/detect.py +0 -0
  21. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/guards.py +0 -0
  22. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/loop_detect.py +0 -0
  23. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/tool_registry.py +0 -0
  24. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/tui/__init__.py +0 -0
  25. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/tui/approval.py +0 -0
  26. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/tui/messages.py +0 -0
  27. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/tuning.py +0 -0
  28. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock_cli.egg-info/SOURCES.txt +0 -0
  29. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock_cli.egg-info/dependency_links.txt +0 -0
  30. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock_cli.egg-info/entry_points.txt +0 -0
  31. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock_cli.egg-info/requires.txt +0 -0
  32. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock_cli.egg-info/top_level.txt +0 -0
  33. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/setup.cfg +0 -0
  34. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_approval.py +0 -0
  35. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_back_command.py +0 -0
  36. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_bash_safety.py +0 -0
  37. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_cli_agents.py +0 -0
  38. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_compaction.py +0 -0
  39. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_config.py +0 -0
  40. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_detect.py +0 -0
  41. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_empty_response.py +0 -0
  42. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_first_run_setup.py +0 -0
  43. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_guards_and_tools.py +0 -0
  44. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_hallucinated_tools.py +0 -0
  45. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_leaked_tool_call.py +0 -0
  46. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_loop_detect.py +0 -0
  47. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_plan_autocontinue.py +0 -0
  48. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_providers_unreachable.py +0 -0
  49. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_subagent.py +0 -0
  50. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_todo.py +0 -0
  51. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_tool_arg_parsing.py +0 -0
  52. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_tools_undo.py +0 -0
  53. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_tui.py +0 -0
  54. {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_tuning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.27
3
+ Version: 3.0.29
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -7,4 +7,4 @@ try:
7
7
  __version__ = _version("drydock-cli")
8
8
  except PackageNotFoundError:
9
9
  # Running from source before install (e.g. PYTHONPATH dev runs).
10
- __version__ = "3.0.27"
10
+ __version__ = "3.0.29"
@@ -251,6 +251,18 @@ def run(
251
251
  if tc["name"] in ("Edit", "Write"):
252
252
  session_has_edited = True
253
253
 
254
+ # STOP pressed: don't run the remaining tools, but still record a
255
+ # paired result for each (the assistant message already lists all
256
+ # tool_calls — leaving one without a tool result corrupts history).
257
+ if _stopped():
258
+ state.messages.append({
259
+ "role": "tool",
260
+ "tool_call_id": tc["id"],
261
+ "name": tc["name"],
262
+ "content": "[skipped — stopped by user]",
263
+ })
264
+ continue
265
+
254
266
  # Check tool call limit
255
267
  if max_tool_calls > 0 and tool_call_count > max_tool_calls:
256
268
  state.messages.append({
@@ -7,11 +7,22 @@ DryDock v3 — clean rewrite, no model-specific tool call parsers.
7
7
  """
8
8
  from __future__ import annotations
9
9
 
10
+ import concurrent.futures
10
11
  import json
11
12
  import os
12
13
  from dataclasses import dataclass
13
14
  from typing import Generator
14
15
 
16
+ # Run blocking LLM calls here so STOP can ABANDON one mid-decode: closing the
17
+ # HTTP client does NOT interrupt an in-flight blocking read, so instead we wait
18
+ # on a future and, when the user cancels, return immediately and let the
19
+ # orphaned request finish (and be discarded) in the background.
20
+ _LLM_POOL = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix="llm")
21
+
22
+
23
+ class _StopRequested(Exception):
24
+ """Internal: STOP was pressed during a blocking LLM call."""
25
+
15
26
  from drydock.tuning import strip_leaked_tool_calls, strip_thinking_tokens, use_streaming
16
27
 
17
28
  # ── Provider registry ─────────────────────────────────────────────────────
@@ -71,6 +82,57 @@ def _safe_create(client, kwargs: dict, base_url: str, provider: str):
71
82
  raise LLMUnreachable(_friendly_unreachable(base_url, provider)) from e
72
83
 
73
84
 
85
+ def _create_abortable(client, kwargs: dict, base_url: str, provider: str, cancel):
86
+ """Like _safe_create, but runs off-thread and polls a cancel Event so STOP
87
+ can abandon a blocked decode (raising _StopRequested). The orphaned request
88
+ runs to completion in the pool thread and its result is dropped."""
89
+ import openai
90
+
91
+ fut = _LLM_POOL.submit(client.chat.completions.create, **kwargs)
92
+ while True:
93
+ try:
94
+ return fut.result(timeout=0.2)
95
+ except concurrent.futures.TimeoutError:
96
+ if cancel is not None and cancel.is_set():
97
+ raise _StopRequested
98
+ except openai.APIConnectionError as e:
99
+ raise LLMUnreachable(_friendly_unreachable(base_url, provider)) from e
100
+
101
+
102
+ def _stream_chunks(response, cancel):
103
+ """Yield streamed chunks, but pull them via a background thread + queue so a
104
+ STOP can abandon a read that's blocked waiting for the first token (the
105
+ thinking phase). On cancel we just stop reading; the orphaned producer
106
+ drains in the background."""
107
+ import queue as _queue
108
+
109
+ q: _queue.Queue = _queue.Queue()
110
+ _DONE = object()
111
+
112
+ def _produce():
113
+ try:
114
+ for ch in response:
115
+ q.put(ch)
116
+ except Exception as e: # noqa: BLE001 — surface to the consumer
117
+ q.put(e)
118
+ finally:
119
+ q.put(_DONE)
120
+
121
+ _LLM_POOL.submit(_produce)
122
+ while True:
123
+ if cancel is not None and cancel.is_set():
124
+ return
125
+ try:
126
+ item = q.get(timeout=0.2)
127
+ except _queue.Empty:
128
+ continue
129
+ if item is _DONE:
130
+ return
131
+ if isinstance(item, Exception):
132
+ raise item
133
+ yield item
134
+
135
+
74
136
  # ── Event types ───────────────────────────────────────────────────────────
75
137
 
76
138
  @dataclass
@@ -196,6 +258,13 @@ def stream(
196
258
  max_retries=0,
197
259
  timeout=httpx.Timeout(600.0, connect=10.0),
198
260
  )
261
+ # Expose the client so the TUI's STOP (Esc / Ctrl+C) can close it mid-call —
262
+ # closing the connection aborts an in-flight blocking decode immediately
263
+ # instead of waiting out the whole generation.
264
+ cancel = config.get("_cancel")
265
+ # Stash in the SHARED abort holder (a mutable dict that survives run()'s
266
+ # dict(config) shallow-copy) so the TUI's STOP can reach this exact client.
267
+ config.setdefault("_abort", {})["client"] = client
199
268
 
200
269
  oai_messages = messages_to_openai(messages, system)
201
270
 
@@ -231,16 +300,30 @@ def stream(
231
300
  if not do_stream:
232
301
  # Non-streaming: one request, parse the complete message. This is the
233
302
  # reliable path for local models whose tool-call JSON breaks under
234
- # streaming.
235
- yield from _complete_nonstreaming(client, kwargs, base_url, provider)
303
+ # streaming. If STOP closes the client mid-call the blocking request
304
+ # raises swallow that as a clean cancel rather than an error.
305
+ try:
306
+ yield from _complete_nonstreaming(client, kwargs, base_url, provider, cancel)
307
+ except _StopRequested:
308
+ return # STOP — abandon the in-flight request, clean stop
309
+ except Exception:
310
+ if cancel is not None and cancel.is_set():
311
+ return
312
+ raise
313
+ finally:
314
+ config.get("_abort", {}).pop("client", None)
236
315
  return
237
316
 
238
317
  text = ""
239
318
  tool_buf: dict = {} # index → {id, name, args}
240
319
  in_tok = out_tok = 0
241
320
 
242
- response = _safe_create(client, kwargs, base_url, provider)
243
- for chunk in response:
321
+ try:
322
+ response = _create_abortable(client, kwargs, base_url, provider, cancel)
323
+ except _StopRequested:
324
+ config.get("_abort", {}).pop("client", None)
325
+ return
326
+ for chunk in _stream_chunks(response, cancel):
244
327
  if not chunk.choices:
245
328
  if hasattr(chunk, "usage") and chunk.usage:
246
329
  in_tok = chunk.usage.prompt_tokens or in_tok
@@ -279,6 +362,9 @@ def stream(
279
362
  in_tok = chunk.usage.prompt_tokens or in_tok
280
363
  out_tok = chunk.usage.completion_tokens or out_tok
281
364
 
365
+ config.get("_abort", {}).pop("client", None) # generation done — drop the abort handle
366
+ if cancel is not None and cancel.is_set():
367
+ return # stopped mid-stream — don't emit a partial turn
282
368
  tool_calls = []
283
369
  for idx in sorted(tool_buf):
284
370
  v = tool_buf[idx]
@@ -293,12 +379,13 @@ def stream(
293
379
 
294
380
 
295
381
  def _complete_nonstreaming(
296
- client, kwargs: dict, base_url: str = "", provider: str = ""
382
+ client, kwargs: dict, base_url: str = "", provider: str = "", cancel=None
297
383
  ) -> Generator:
298
384
  """Single non-streaming completion. Yields one TextChunk (if any text)
299
385
  then the AssistantTurn. Used when streaming would corrupt tool-call JSON.
386
+ Runs off-thread so STOP can abandon a blocked decode.
300
387
  """
301
- resp = _safe_create(client, kwargs, base_url, provider)
388
+ resp = _create_abortable(client, kwargs, base_url, provider, cancel)
302
389
  choice = resp.choices[0]
303
390
  msg = choice.message
304
391
 
@@ -10,6 +10,7 @@ import re
10
10
  import difflib
11
11
  import glob as _glob
12
12
  import subprocess
13
+ import time
13
14
  from pathlib import Path
14
15
 
15
16
  from drydock.tool_registry import ToolDef, register
@@ -457,26 +458,46 @@ def tool_bash(params: dict, config: dict) -> str:
457
458
  f"REFUSED: you declined to approve this command "
458
459
  f"({approval_reason}).\nCommand: {cmd.strip()}"
459
460
  )
461
+ # Run via Popen (not subprocess.run) so STOP can kill it mid-execution:
462
+ # the handle is stashed in config["_abort"]["proc"] for action_stop to .kill().
463
+ # We poll communicate() in short slices, checking the cancel Event and the
464
+ # overall timeout between slices.
465
+ cancel = config.get("_cancel")
466
+ proc = None
460
467
  try:
461
- result = subprocess.run(
462
- cmd, shell=True, capture_output=True, text=True,
463
- timeout=timeout, cwd=config.get("cwd"),
468
+ proc = subprocess.Popen(
469
+ cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
470
+ text=True, cwd=config.get("cwd"),
464
471
  )
465
- output = result.stdout
466
- if result.stderr:
467
- output += "\n" + result.stderr if output else result.stderr
468
- if result.returncode != 0:
469
- output += f"\n[exit code: {result.returncode}]"
472
+ config.setdefault("_abort", {})["proc"] = proc
473
+ start = time.monotonic()
474
+ while True:
475
+ try:
476
+ out, _ = proc.communicate(timeout=0.5)
477
+ break
478
+ except subprocess.TimeoutExpired:
479
+ if cancel is not None and cancel.is_set():
480
+ proc.kill()
481
+ proc.communicate()
482
+ return "[stopped by user]"
483
+ if time.monotonic() - start > timeout:
484
+ proc.kill()
485
+ proc.communicate()
486
+ bigger = min(timeout * 4, 600)
487
+ return (
488
+ f"Error: command timed out after {timeout}s. If it is "
489
+ f"legitimately slow (a big query, build, download, or "
490
+ f"test run), retry with a larger timeout — pass "
491
+ f"timeout: {bigger}. Otherwise it may be hung."
492
+ )
493
+ output = out or ""
494
+ if proc.returncode != 0:
495
+ output += f"\n[exit code: {proc.returncode}]"
470
496
  return output.strip() or "(no output)"
471
- except subprocess.TimeoutExpired:
472
- bigger = min(timeout * 4, 600)
473
- return (
474
- f"Error: command timed out after {timeout}s. If it is legitimately "
475
- f"slow (a big query, build, download, or test run), retry with a "
476
- f"larger timeout — pass timeout: {bigger}. Otherwise it may be hung."
477
- )
478
497
  except Exception as e:
479
498
  return f"Error: {e}"
499
+ finally:
500
+ config.get("_abort", {}).pop("proc", None)
480
501
 
481
502
 
482
503
  def tool_glob(params: dict, config: dict) -> str:
@@ -34,6 +34,7 @@ from drydock.tui.messages import (
34
34
  AgentTurnDone,
35
35
  )
36
36
  from drydock.tui.widgets import (
37
+ SLASH_COMMANDS,
37
38
  AssistantMessage,
38
39
  ErrorMessage,
39
40
  PromptArea,
@@ -135,20 +136,40 @@ class DrydockApp(App):
135
136
  ]
136
137
 
137
138
  def action_stop(self) -> None:
138
- """STOP the running turn: signal the agent loop to end at its next safe
139
- point, drop any queued prompts, and hand control back. The session
140
- (history, files) is preserved this is not a quit."""
139
+ """STOP the running turn NOW (Esc or, while busy, a single Ctrl+C):
140
+ signal the loop AND forcibly abort the in-flight LLM call and any
141
+ running command so it stops immediately rather than after the current
142
+ step. Session (history, files) is preserved — this is not a quit."""
141
143
  if not self._busy:
142
144
  return
143
145
  self._cancel.set()
146
+ self._abort_inflight()
144
147
  dropped = len(self._queue)
145
148
  self._queue.clear()
146
- note = "⏹ stopping after the current step…"
147
- if dropped:
148
- note += f" (discarded {dropped} queued)"
149
+ note = "⏹ stopped." + (f" (discarded {dropped} queued)" if dropped else "")
149
150
  self._info(note)
150
151
  self._refresh_status()
151
152
 
153
+ def _abort_inflight(self) -> None:
154
+ """Forcibly interrupt blocking work so STOP is immediate: close the LLM
155
+ HTTP client (aborts a long decode) and kill any running command. The
156
+ handles live in the shared config["_abort"] holder (a mutable dict that
157
+ survives run()'s dict(config) copy); missing ones just mean nothing is
158
+ in that state right now."""
159
+ abort = self.config.get("_abort") or {}
160
+ client = abort.get("client")
161
+ if client is not None:
162
+ try:
163
+ client.close()
164
+ except Exception: # noqa: BLE001
165
+ pass
166
+ proc = abort.get("proc")
167
+ if proc is not None:
168
+ try:
169
+ proc.kill()
170
+ except Exception: # noqa: BLE001
171
+ pass
172
+
152
173
  def action_scroll_up(self) -> None:
153
174
  self._scroll.scroll_page_up()
154
175
 
@@ -186,7 +207,12 @@ class DrydockApp(App):
186
207
  self.notify(f"Copied {len(selected)} chars to clipboard", timeout=2)
187
208
  self._ctrl_c_armed = False
188
209
  return
189
- # No selection double-Ctrl+C to exit.
210
+ # While a turn is running, a single Ctrl+C STOPS it (like Esc) — the
211
+ # interrupt key when you need to halt the TUI mid-task.
212
+ if self._busy:
213
+ self.action_stop()
214
+ return
215
+ # Idle, no selection → double-Ctrl+C to exit.
190
216
  if self._ctrl_c_armed:
191
217
  self.exit()
192
218
  return
@@ -210,6 +236,10 @@ class DrydockApp(App):
210
236
  # so run() — and any sub-agent that copies config — can see it.
211
237
  self._cancel = threading.Event()
212
238
  self.config["_cancel"] = self._cancel
239
+ # Shared abort-handle holder: the provider stashes the live LLM client
240
+ # and the bash tool its subprocess here, so STOP can force-abort them.
241
+ # A plain dict survives run()'s dict(config) shallow copy by reference.
242
+ self.config["_abort"] = {}
213
243
  self._queue: list[str] = [] # prompts submitted while a turn is running
214
244
  self._ctx_tokens = 0 # current context size (last turn's prompt tokens)
215
245
  self._ctrl_c_armed = False # first Ctrl+C arms; second within ~2s exits
@@ -283,8 +313,10 @@ class DrydockApp(App):
283
313
  pct = min(100, round(used / limit * 100)) if limit else 0
284
314
  ctx = f"ctx {used:,}/{limit // 1000}k ({pct}%)"
285
315
  flag = "⚓ working" if self._busy else "⚓ ready"
316
+ # Busy → show the interrupt keys; idle → the quit hint.
317
+ keys = "Esc/Ctrl+C stop" if self._busy else "Ctrl+C×2 quit"
286
318
  return (
287
- f"{flag} · {model} · Ctrl+C×2 quit · PgUp/PgDn scroll · "
319
+ f"{flag} · {model} · {keys} · PgUp/PgDn scroll · "
288
320
  f"Ctrl+O details · {ctx}"
289
321
  )
290
322
 
@@ -320,6 +352,22 @@ class DrydockApp(App):
320
352
 
321
353
  # ── input ─────────────────────────────────────────────────────────────
322
354
 
355
+ def on_text_area_changed(self, event) -> None:
356
+ """As the user types a bare slash command, surface the matching commands
357
+ in the (idle) activity line — '/m' shows '/model'. Tab completes them."""
358
+ if self._busy:
359
+ return # #working shows live activity while a turn is running
360
+ try:
361
+ text = self.query_one("#prompt", PromptArea).text
362
+ except Exception: # noqa: BLE001 — widget not mounted yet
363
+ return
364
+ hint = ""
365
+ if text.startswith("/") and " " not in text and "\n" not in text:
366
+ matches = [c for c in SLASH_COMMANDS if c.startswith(text.lower())]
367
+ if matches:
368
+ hint = " " + " ".join(matches) + " · Tab to complete"
369
+ self.query_one("#working", Static).update(hint)
370
+
323
371
  def on_prompt_area_submitted(self, event: PromptArea.Submitted) -> None:
324
372
  text = event.text.strip()
325
373
  prompt = self.query_one("#prompt", PromptArea)
@@ -148,6 +148,25 @@ class PromptHistory:
148
148
  return self._draft
149
149
 
150
150
 
151
+ # The slash commands offered for completion (typing "/m" → /model). Keep in
152
+ # sync with app._handle_slash. Shown as a hint as the user types, and Tab
153
+ # completes the prefix.
154
+ SLASH_COMMANDS = [
155
+ "/help", "/model", "/cwd", "/undo", "/back", "/stop", "/status",
156
+ "/clear", "/quit",
157
+ ]
158
+
159
+
160
+ def _common_prefix(strings: list[str]) -> str:
161
+ if not strings:
162
+ return ""
163
+ pre = strings[0]
164
+ for s in strings[1:]:
165
+ while not s.startswith(pre):
166
+ pre = pre[:-1]
167
+ return pre
168
+
169
+
151
170
  class PromptArea(TextArea):
152
171
  """Multi-line prompt box.
153
172
 
@@ -187,6 +206,20 @@ class PromptArea(TextArea):
187
206
  event.prevent_default()
188
207
  self.insert("\n")
189
208
  return
209
+ if key == "tab":
210
+ t = self.text
211
+ if t.startswith("/") and " " not in t and "\n" not in t:
212
+ matches = [c for c in SLASH_COMMANDS if c.startswith(t.lower())]
213
+ if matches:
214
+ event.stop()
215
+ event.prevent_default()
216
+ # one match → complete it (+ space); several → common prefix.
217
+ self.text = (
218
+ matches[0] + " " if len(matches) == 1
219
+ else _common_prefix(matches)
220
+ )
221
+ self.move_cursor(self.document.end)
222
+ return
190
223
  if key == "up" and self.cursor_at_first_line:
191
224
  event.stop()
192
225
  event.prevent_default()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.27
3
+ Version: 3.0.29
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
  # PyPI distribution name is drydock-cli (the established install name, continued
8
8
  # from the retired v2 fork); the import package + CLI command stay `drydock`.
9
9
  name = "drydock-cli"
10
- version = "3.0.27"
10
+ version = "3.0.29"
11
11
  description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.12"
@@ -62,3 +62,28 @@ def test_no_cancel_event_runs_normally(monkeypatch):
62
62
  st = AgentState()
63
63
  list(run("go", st, {"model": "m"}, "sys"))
64
64
  assert any(m.get("role") == "assistant" for m in st.messages)
65
+
66
+
67
+ def test_create_abortable_raises_on_cancel():
68
+ """_create_abortable runs the blocking call off-thread and abandons it when
69
+ the cancel Event fires — so STOP doesn't wait out a long decode."""
70
+ import threading
71
+ import time
72
+ import pytest
73
+ from drydock.providers import _create_abortable, _StopRequested
74
+
75
+ cancel = threading.Event()
76
+
77
+ class _SlowClient:
78
+ class chat:
79
+ class completions:
80
+ @staticmethod
81
+ def create(**kw):
82
+ time.sleep(30) # simulate a long decode (orphaned on cancel)
83
+ return "never"
84
+
85
+ threading.Timer(0.3, cancel.set).start()
86
+ t0 = time.monotonic()
87
+ with pytest.raises(_StopRequested):
88
+ _create_abortable(_SlowClient(), {}, "", "", cancel)
89
+ assert time.monotonic() - t0 < 5 # returned promptly, didn't wait 30s
@@ -36,7 +36,7 @@ def test_streaming_preserves_newline_only_chunks(monkeypatch):
36
36
  _Chunk("\n\n"),
37
37
  _Chunk("body text"),
38
38
  ]
39
- monkeypatch.setattr(P, "_safe_create", lambda *a, **k: iter(chunks))
39
+ monkeypatch.setattr(P, "_create_abortable", lambda *a, **k: iter(chunks))
40
40
 
41
41
  full = ""
42
42
  streamed = []
@@ -60,7 +60,7 @@ def test_streaming_still_drops_chunk_emptied_by_stripping(monkeypatch):
60
60
  # A chunk that is ONLY a thinking marker strips to "" and is skipped (so we
61
61
  # don't emit empty text), but real whitespace is kept.
62
62
  chunks = [_Chunk("hello"), _Chunk("<|channel>x<channel|>"), _Chunk("\n"), _Chunk("world")]
63
- monkeypatch.setattr(P, "_safe_create", lambda *a, **k: iter(chunks))
63
+ monkeypatch.setattr(P, "_create_abortable", lambda *a, **k: iter(chunks))
64
64
  full = ""
65
65
  for ev in stream(model="gemma4", system="s",
66
66
  messages=[{"role": "user", "content": "hi"}],
File without changes
File without changes
File without changes
File without changes