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.
- {drydock_cli-3.0.27/drydock_cli.egg-info → drydock_cli-3.0.29}/PKG-INFO +1 -1
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/__init__.py +1 -1
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/agent.py +12 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/providers.py +93 -6
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/tools/__init__.py +36 -15
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/tui/app.py +56 -8
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/tui/widgets.py +33 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/pyproject.toml +1 -1
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_stop.py +25 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_streaming_newlines.py +2 -2
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/LICENSE +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/NOTICE +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/README.md +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/cli.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/config.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/detect.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/guards.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock_cli.egg-info/SOURCES.txt +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/setup.cfg +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_config.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.27 → drydock_cli-3.0.29}/tests/test_tuning.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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 =
|
|
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
|
-
|
|
462
|
-
cmd, shell=True,
|
|
463
|
-
|
|
468
|
+
proc = subprocess.Popen(
|
|
469
|
+
cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
470
|
+
text=True, cwd=config.get("cwd"),
|
|
464
471
|
)
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
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 = "⏹
|
|
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
|
-
#
|
|
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} ·
|
|
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()
|
|
@@ -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.
|
|
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, "
|
|
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, "
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|