drydock-cli 3.0.51__tar.gz → 3.0.53__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 (71) hide show
  1. {drydock_cli-3.0.51/drydock_cli.egg-info → drydock_cli-3.0.53}/PKG-INFO +1 -1
  2. drydock_cli-3.0.53/drydock/skills.py +90 -0
  3. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/tui/app.py +73 -1
  4. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/tui/widgets.py +1 -1
  5. {drydock_cli-3.0.51 → drydock_cli-3.0.53/drydock_cli.egg-info}/PKG-INFO +1 -1
  6. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock_cli.egg-info/SOURCES.txt +2 -0
  7. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/pyproject.toml +1 -1
  8. drydock_cli-3.0.53/tests/test_skills.py +56 -0
  9. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_tui.py +37 -0
  10. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/LICENSE +0 -0
  11. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/NOTICE +0 -0
  12. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/README.md +0 -0
  13. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/__init__.py +0 -0
  14. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/__main__.py +0 -0
  15. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/agent.py +0 -0
  16. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/bash_safety.py +0 -0
  17. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/cli.py +0 -0
  18. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/compaction.py +0 -0
  19. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/config.py +0 -0
  20. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/detect.py +0 -0
  21. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/gittools.py +0 -0
  22. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/graphrag.py +0 -0
  23. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/guards.py +0 -0
  24. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/loop_detect.py +0 -0
  25. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/providers.py +0 -0
  26. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/tool_registry.py +0 -0
  27. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/tools/__init__.py +0 -0
  28. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/tui/__init__.py +0 -0
  29. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/tui/approval.py +0 -0
  30. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/tui/messages.py +0 -0
  31. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/tuning.py +0 -0
  32. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock/web.py +0 -0
  33. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock_cli.egg-info/dependency_links.txt +0 -0
  34. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock_cli.egg-info/entry_points.txt +0 -0
  35. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock_cli.egg-info/requires.txt +0 -0
  36. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/drydock_cli.egg-info/top_level.txt +0 -0
  37. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/setup.cfg +0 -0
  38. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_approval.py +0 -0
  39. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_back_command.py +0 -0
  40. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_bash_process_group.py +0 -0
  41. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_bash_safety.py +0 -0
  42. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_bash_timeout_network.py +0 -0
  43. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_cli_agents.py +0 -0
  44. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_compact_command.py +0 -0
  45. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_compaction.py +0 -0
  46. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_config.py +0 -0
  47. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_config_migration.py +0 -0
  48. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_context_limit_config.py +0 -0
  49. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_detect.py +0 -0
  50. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_empty_response.py +0 -0
  51. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_failure_loop.py +0 -0
  52. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_first_run_setup.py +0 -0
  53. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_gittools.py +0 -0
  54. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_graphrag.py +0 -0
  55. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_guards_and_tools.py +0 -0
  56. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_hallucinated_tools.py +0 -0
  57. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_leaked_tool_call.py +0 -0
  58. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_loop_detect.py +0 -0
  59. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_oneshot_unreachable.py +0 -0
  60. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_plan_autocontinue.py +0 -0
  61. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_providers_unreachable.py +0 -0
  62. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_runaway_repetition.py +0 -0
  63. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_stop.py +0 -0
  64. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_streaming_newlines.py +0 -0
  65. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_subagent.py +0 -0
  66. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_todo.py +0 -0
  67. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_tool_arg_parsing.py +0 -0
  68. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_tools_undo.py +0 -0
  69. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_tuning.py +0 -0
  70. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_vision_input.py +0 -0
  71. {drydock_cli-3.0.51 → drydock_cli-3.0.53}/tests/test_web_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.51
3
+ Version: 3.0.53
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
@@ -0,0 +1,90 @@
1
+ """Skills — user-defined reusable slash commands.
2
+
3
+ A skill is a markdown file with simple frontmatter; its body is a prompt the
4
+ user invokes as ``/<name>`` in the TUI (or --cli). It saves retyping common
5
+ instructions ("/review", "/explain", "/testgen") and ships project conventions
6
+ alongside the code.
7
+
8
+ Discovery (project overrides user on a name clash):
9
+ • ~/.drydock/skills/*.md (personal, all projects)
10
+ • <cwd>/.drydock/skills/*.md (this project)
11
+
12
+ File format (frontmatter optional; name defaults to the filename):
13
+ ---
14
+ name: review
15
+ description: Review the current diff for bugs
16
+ ---
17
+ Run GitDiff, then review the changes for bugs and suggest fixes.
18
+
19
+ Invocation:
20
+ • ``/review`` → the body becomes the user turn.
21
+ • ``/review focus on perf`` → trailing text replaces ``$ARGS`` in the body,
22
+ or is appended if the body has no ``$ARGS`` placeholder.
23
+
24
+ All logic original to Drydock.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+
32
+ _FRONTMATTER = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.S)
33
+ _NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$", re.I)
34
+
35
+
36
+ @dataclass
37
+ class Skill:
38
+ name: str
39
+ description: str
40
+ body: str
41
+ source: str # file path, for /skills listing
42
+
43
+ def render(self, args: str = "") -> str:
44
+ """Expand the skill into a prompt, substituting/append args."""
45
+ args = (args or "").strip()
46
+ if "$ARGS" in self.body:
47
+ return self.body.replace("$ARGS", args)
48
+ return f"{self.body}\n\n{args}".rstrip() if args else self.body
49
+
50
+
51
+ def _parse(path: Path) -> Skill | None:
52
+ try:
53
+ text = path.read_text("utf-8", "ignore")
54
+ except OSError:
55
+ return None
56
+ name = path.stem.lower()
57
+ description = ""
58
+ body = text
59
+ m = _FRONTMATTER.match(text)
60
+ if m:
61
+ meta, body = m.group(1), m.group(2)
62
+ for line in meta.splitlines():
63
+ if ":" in line:
64
+ key, _, val = line.partition(":")
65
+ key, val = key.strip().lower(), val.strip()
66
+ if key == "name" and val:
67
+ name = val.lower()
68
+ elif key == "description":
69
+ description = val
70
+ body = body.strip()
71
+ if not body or not _NAME_RE.match(name):
72
+ return None
73
+ return Skill(name=name, description=description, body=body, source=str(path))
74
+
75
+
76
+ def skills_dirs(cwd: str) -> list[Path]:
77
+ return [Path.home() / ".drydock" / "skills", Path(cwd) / ".drydock" / "skills"]
78
+
79
+
80
+ def load_skills(cwd: str) -> dict[str, Skill]:
81
+ """Load all skills; later dirs (project) override earlier (user) by name."""
82
+ skills: dict[str, Skill] = {}
83
+ for d in skills_dirs(cwd):
84
+ if not d.is_dir():
85
+ continue
86
+ for f in sorted(d.glob("*.md")):
87
+ sk = _parse(f)
88
+ if sk:
89
+ skills[sk.name] = sk
90
+ return skills
@@ -153,7 +153,10 @@ class DrydockApp(App):
153
153
  self._abort_inflight()
154
154
  dropped = len(self._queue)
155
155
  self._queue.clear()
156
+ looping = self._repeat is not None
157
+ self._repeat = None # Esc/stop also ends an active /loop
156
158
  note = "⏹ stopped." + (f" (discarded {dropped} queued)" if dropped else "")
159
+ note += " loop ended." if looping else ""
157
160
  self._info(note)
158
161
  self._refresh_status()
159
162
 
@@ -236,6 +239,8 @@ class DrydockApp(App):
236
239
  self.config = config
237
240
  self.state = AgentState()
238
241
  self.system = self._build_system(config.get("model"))
242
+ from drydock.skills import load_skills
243
+ self._skills = load_skills(config.get("cwd") or ".")
239
244
  self._current_assistant: AssistantMessage | None = None
240
245
  self._last_card: ToolCard | None = None
241
246
  self._busy = False
@@ -249,6 +254,7 @@ class DrydockApp(App):
249
254
  # A plain dict survives run()'s dict(config) shallow copy by reference.
250
255
  self.config["_abort"] = {}
251
256
  self._queue: list[str] = [] # prompts submitted while a turn is running
257
+ self._repeat: dict | None = None # active /loop: {prompt, remaining, total}
252
258
  self._ctx_tokens = 0 # current context size (last turn's prompt tokens)
253
259
  self._ctrl_c_armed = False # first Ctrl+C arms; second within ~2s exits
254
260
  # Live "working" line state.
@@ -375,7 +381,8 @@ class DrydockApp(App):
375
381
  return
376
382
  hint = ""
377
383
  if text.startswith("/") and " " not in text and "\n" not in text:
378
- matches = [c for c in SLASH_COMMANDS if c.startswith(text.lower())]
384
+ commands = SLASH_COMMANDS + ["/skills"] + [f"/{n}" for n in sorted(self._skills)]
385
+ matches = [c for c in commands if c.startswith(text.lower())]
379
386
  if matches:
380
387
  hint = " " + " ".join(matches) + " · Tab to complete"
381
388
  self.query_one("#working", Static).update(hint)
@@ -485,13 +492,69 @@ class DrydockApp(App):
485
492
  " /compact shrink old context to free up the window\n"
486
493
  " /graphrag build/query a knowledge base from your docs\n"
487
494
  " /graphrag build <path> · /graphrag status · /graphrag clear\n"
495
+ " /skills list your reusable /<name> skills\n"
496
+ " /loop /loop <count> <prompt> — repeat a prompt (Esc stops)\n"
488
497
  " /clear reset the conversation\n"
489
498
  " /quit exit\n"
490
499
  "Type a task and press Enter. ↑/↓ recall history · Esc stops · Ctrl+O expands tools."
491
500
  )
501
+ elif cmd == "/loop":
502
+ self._cmd_loop(arg)
503
+ elif cmd == "/skills":
504
+ self._cmd_skills()
505
+ elif cmd[1:] in self._skills:
506
+ self._run_skill(self._skills[cmd[1:]], arg)
492
507
  else:
493
508
  self._mount(ErrorMessage(f"unknown command: {cmd} (try /help)"))
494
509
 
510
+ def _cmd_loop(self, arg: str) -> None:
511
+ """/loop <count> <prompt> — run <prompt> up to <count> times (1–50),
512
+ re-submitting after each turn finishes. Esc (or /stop) ends the loop."""
513
+ parts = arg.split(maxsplit=1)
514
+ if len(parts) < 2 or not parts[0].isdigit():
515
+ self._info(
516
+ "usage: /loop <count> <prompt> e.g. /loop 5 fix the next "
517
+ "failing test and run pytest\n(count 1–50; Esc stops the loop)"
518
+ )
519
+ return
520
+ count = max(1, min(int(parts[0]), 50))
521
+ prompt = parts[1].strip()
522
+ if not prompt:
523
+ self._info("usage: /loop <count> <prompt>")
524
+ return
525
+ if self._busy:
526
+ self._info("A turn is already running — stop it (Esc) before starting a loop.")
527
+ return
528
+ self._repeat = {"prompt": prompt, "remaining": count, "total": count}
529
+ self._info(f"↻ looping {count}× (Esc to stop): {prompt}")
530
+ self._mount(UserMessage(prompt))
531
+ self._begin(prompt)
532
+
533
+ def _cmd_skills(self) -> None:
534
+ if not self._skills:
535
+ self._info(
536
+ "No skills yet. Create one as a markdown file in "
537
+ "~/.drydock/skills/<name>.md (or <project>/.drydock/skills/), "
538
+ "then invoke it as /<name>. The body is the prompt; use $ARGS for "
539
+ "trailing input."
540
+ )
541
+ return
542
+ lines = ["Skills (invoke as /<name>):"]
543
+ for name in sorted(self._skills):
544
+ sk = self._skills[name]
545
+ lines.append(f" /{name}" + (f" — {sk.description}" if sk.description else ""))
546
+ self._info("\n".join(lines))
547
+
548
+ def _run_skill(self, skill, arg: str) -> None:
549
+ """Expand a skill into a prompt and run it as a normal user turn."""
550
+ prompt = skill.render(arg)
551
+ self._mount(UserMessage(f"/{skill.name}" + (f" {arg}" if arg else "")))
552
+ if self._busy:
553
+ self._queue.append(prompt)
554
+ self._refresh_status()
555
+ else:
556
+ self._begin(prompt)
557
+
495
558
  def _cmd_compact(self) -> None:
496
559
  """Manually compact the conversation to reclaim context NOW, without
497
560
  waiting for the automatic 60%-of-window threshold (agent.maybe_compact).
@@ -747,6 +810,15 @@ class DrydockApp(App):
747
810
  if self._queue:
748
811
  self._begin(self._queue.pop(0))
749
812
  return
813
+ # /loop: re-run the prompt until the iteration count is exhausted (Esc/
814
+ # stop clears self._repeat). Queued user prompts take priority (above).
815
+ if self._repeat and self._repeat["remaining"] > 1 and not self._cancel.is_set():
816
+ self._repeat["remaining"] -= 1
817
+ done = self._repeat["total"] - self._repeat["remaining"] + 1
818
+ self._info(f"↻ loop iteration {done}/{self._repeat['total']}")
819
+ self._begin(self._repeat["prompt"])
820
+ return
821
+ self._repeat = None
750
822
  self._refresh_status()
751
823
  self.query_one("#prompt", PromptArea).focus()
752
824
 
@@ -153,7 +153,7 @@ class PromptHistory:
153
153
  # completes the prefix.
154
154
  SLASH_COMMANDS = [
155
155
  "/help", "/model", "/cwd", "/undo", "/back", "/stop", "/status",
156
- "/compact", "/graphrag", "/clear", "/quit",
156
+ "/compact", "/graphrag", "/skills", "/loop", "/clear", "/quit",
157
157
  ]
158
158
 
159
159
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.51
3
+ Version: 3.0.53
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
@@ -15,6 +15,7 @@ drydock/graphrag.py
15
15
  drydock/guards.py
16
16
  drydock/loop_detect.py
17
17
  drydock/providers.py
18
+ drydock/skills.py
18
19
  drydock/tool_registry.py
19
20
  drydock/tuning.py
20
21
  drydock/web.py
@@ -55,6 +56,7 @@ tests/test_oneshot_unreachable.py
55
56
  tests/test_plan_autocontinue.py
56
57
  tests/test_providers_unreachable.py
57
58
  tests/test_runaway_repetition.py
59
+ tests/test_skills.py
58
60
  tests/test_stop.py
59
61
  tests/test_streaming_newlines.py
60
62
  tests/test_subagent.py
@@ -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.51"
10
+ version = "3.0.53"
11
11
  description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -0,0 +1,56 @@
1
+ """Skills: user-defined reusable /<name> slash commands loaded from markdown."""
2
+ from __future__ import annotations
3
+
4
+ from drydock import skills
5
+
6
+
7
+ def _write(d, name, content):
8
+ d.mkdir(parents=True, exist_ok=True)
9
+ (d / name).write_text(content)
10
+
11
+
12
+ def test_load_parses_frontmatter_and_body(tmp_path):
13
+ _write(tmp_path / ".drydock" / "skills", "review.md",
14
+ "---\nname: review\ndescription: Review the diff\n---\nRun GitDiff and review it.")
15
+ sk = skills.load_skills(str(tmp_path))
16
+ assert "review" in sk
17
+ assert sk["review"].description == "Review the diff"
18
+ assert sk["review"].body == "Run GitDiff and review it."
19
+
20
+
21
+ def test_name_defaults_to_filename(tmp_path):
22
+ _write(tmp_path / ".drydock" / "skills", "explain.md", "Explain the selected code.")
23
+ sk = skills.load_skills(str(tmp_path))
24
+ assert "explain" in sk and sk["explain"].body == "Explain the selected code."
25
+
26
+
27
+ def test_render_appends_args_without_placeholder(tmp_path):
28
+ _write(tmp_path / ".drydock" / "skills", "t.md", "Do the thing.")
29
+ sk = skills.load_skills(str(tmp_path))["t"]
30
+ assert sk.render("on auth.py") == "Do the thing.\n\non auth.py"
31
+ assert sk.render("") == "Do the thing."
32
+
33
+
34
+ def test_render_substitutes_args_placeholder(tmp_path):
35
+ _write(tmp_path / ".drydock" / "skills", "t.md", "Focus on $ARGS and nothing else.")
36
+ sk = skills.load_skills(str(tmp_path))["t"]
37
+ assert sk.render("performance") == "Focus on performance and nothing else."
38
+
39
+
40
+ def test_project_overrides_user(tmp_path, monkeypatch):
41
+ home = tmp_path / "home"
42
+ proj = tmp_path / "proj"
43
+ monkeypatch.setattr(skills.Path, "home", lambda: home)
44
+ _write(home / ".drydock" / "skills", "x.md", "USER version")
45
+ _write(proj / ".drydock" / "skills", "x.md", "PROJECT version")
46
+ sk = skills.load_skills(str(proj))
47
+ assert sk["x"].body == "PROJECT version"
48
+
49
+
50
+ def test_empty_body_skipped(tmp_path):
51
+ _write(tmp_path / ".drydock" / "skills", "empty.md", "---\nname: empty\n---\n")
52
+ assert "empty" not in skills.load_skills(str(tmp_path))
53
+
54
+
55
+ def test_no_skills_dir_is_empty(tmp_path):
56
+ assert skills.load_skills(str(tmp_path)) == {}
@@ -348,3 +348,40 @@ def test_new_user_turn_clears_stale_plan():
348
348
  assert str(panel.render()) == "" # panel cleared
349
349
 
350
350
  asyncio.run(main())
351
+
352
+
353
+ def test_loop_command_iterates_and_stops():
354
+ """/loop <n> <prompt> re-runs the prompt n times, then clears; Esc ends it."""
355
+ async def main():
356
+ app = DrydockApp({"model": "gemma4", "provider": "vllm", "cwd": "/tmp"})
357
+ async with app.run_test() as pilot:
358
+ await pilot.pause()
359
+ begins = []
360
+ app._begin = lambda text: begins.append(text) # stub out the real agent
361
+
362
+ # bad usage → no loop
363
+ app._handle_slash("/loop notanumber")
364
+ assert app._repeat is None and not begins
365
+
366
+ # /loop 3 <prompt> → starts, first iteration begun
367
+ app._handle_slash("/loop 3 do the thing")
368
+ assert app._repeat and app._repeat["total"] == 3
369
+ assert begins == ["do the thing"]
370
+
371
+ # each finished turn re-begins until the count is exhausted
372
+ from drydock.tui.messages import AgentFinished
373
+ app.on_agent_finished(AgentFinished()) # iter 2
374
+ app.on_agent_finished(AgentFinished()) # iter 3
375
+ assert len(begins) == 3
376
+ app.on_agent_finished(AgentFinished()) # done → loop cleared
377
+ assert app._repeat is None
378
+ assert len(begins) == 3 # no 4th run
379
+
380
+ # Esc/stop ends an active loop
381
+ app._begin = lambda text: begins.append(text)
382
+ app._handle_slash("/loop 5 again")
383
+ app._busy = True
384
+ app.action_stop()
385
+ assert app._repeat is None
386
+
387
+ asyncio.run(main())
File without changes
File without changes
File without changes
File without changes