lcode-cli 0.37.0__tar.gz → 0.38.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 (178) hide show
  1. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/CHANGELOG.md +34 -1
  2. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/PKG-INFO +1 -1
  3. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/how-it-works.md +4 -2
  4. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/quickstart.md +1 -1
  5. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/usage.md +24 -1
  6. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/__init__.py +1 -1
  7. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/agent.py +32 -4
  8. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/checks.py +95 -17
  9. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/repeats.py +11 -2
  10. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/screenshots.py +237 -10
  11. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/tools.py +15 -1
  12. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_checks.py +30 -0
  13. lcode_cli-0.38.0/tests/test_look.py +227 -0
  14. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_stuck.py +13 -0
  15. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/.editorconfig +0 -0
  16. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/.github/CODEOWNERS +0 -0
  17. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  18. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  19. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  20. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  21. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  22. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/.github/dependabot.yml +0 -0
  23. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/.github/workflows/ci.yml +0 -0
  24. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/.github/workflows/docs.yml +0 -0
  25. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/.github/workflows/release.yml +0 -0
  26. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/.gitignore +0 -0
  27. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/.pre-commit-config.yaml +0 -0
  28. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/AGENTS.md +0 -0
  29. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/CODE_OF_CONDUCT.md +0 -0
  30. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/CONTRIBUTING.md +0 -0
  31. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/LICENSE +0 -0
  32. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/README.md +0 -0
  33. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/SECURITY.md +0 -0
  34. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/action.yml +0 -0
  35. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/agents.md +0 -0
  36. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/assets/demo.svg +0 -0
  37. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/assets/extra.css +0 -0
  38. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/automation.md +0 -0
  39. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/bench.md +0 -0
  40. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/changelog.md +0 -0
  41. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/commands.md +0 -0
  42. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/configuration.md +0 -0
  43. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/contributing.md +0 -0
  44. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/editors.md +0 -0
  45. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/git.md +0 -0
  46. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/hooks.md +0 -0
  47. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/index.md +0 -0
  48. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/installation.md +0 -0
  49. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/lsp.md +0 -0
  50. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/mcp.md +0 -0
  51. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/memory.md +0 -0
  52. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/models.md +0 -0
  53. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/sandbox.md +0 -0
  54. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/search.md +0 -0
  55. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/servers.md +0 -0
  56. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/docs/troubleshooting.md +0 -0
  57. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/examples/README.md +0 -0
  58. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/examples/api/fix_tests.py +0 -0
  59. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/examples/commands/review.md +0 -0
  60. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/examples/skills/commit-message/SKILL.md +0 -0
  61. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
  62. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/install.sh +0 -0
  63. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/mkdocs.yml +0 -0
  64. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/overrides/main.html +0 -0
  65. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/pyproject.toml +0 -0
  66. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/scripts/make_logos.py +0 -0
  67. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/scripts/record_demo.py +0 -0
  68. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/scripts/social-preview.html +0 -0
  69. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/__main__.py +0 -0
  70. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/acp.py +0 -0
  71. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/action.py +0 -0
  72. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/api.py +0 -0
  73. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/backends.py +0 -0
  74. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/bench.py +0 -0
  75. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/bench_backend.py +0 -0
  76. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/bench_runtime.py +0 -0
  77. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/catalog.py +0 -0
  78. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/checkpoints.py +0 -0
  79. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/cli.py +0 -0
  80. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/clipboard.py +0 -0
  81. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/cloud.py +0 -0
  82. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/codesearch.py +0 -0
  83. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/config.py +0 -0
  84. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/context.py +0 -0
  85. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/escalate.py +0 -0
  86. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/extensions.py +0 -0
  87. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/frontmatter.py +0 -0
  88. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/gitflow.py +0 -0
  89. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/hardware.py +0 -0
  90. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/hooks.py +0 -0
  91. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/jobs.py +0 -0
  92. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/limits.py +0 -0
  93. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/lsp.py +0 -0
  94. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/matching.py +0 -0
  95. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/mcp/__init__.py +0 -0
  96. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/mcp/auth.py +0 -0
  97. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/mcp/catalog.py +0 -0
  98. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/mcp/catalog.toml +0 -0
  99. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/mcp/client.py +0 -0
  100. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/mcp/commands.py +0 -0
  101. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/mcp/config.py +0 -0
  102. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/mcp/http.py +0 -0
  103. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/mcp/manager.py +0 -0
  104. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/mcp/protocol.py +0 -0
  105. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/mcp/server.py +0 -0
  106. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/mcp/stdio.py +0 -0
  107. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/memory.py +0 -0
  108. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/models.toml +0 -0
  109. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/notify.py +0 -0
  110. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/ollama.py +0 -0
  111. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/permissions.py +0 -0
  112. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/planning.py +0 -0
  113. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/render.py +0 -0
  114. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/repl.py +0 -0
  115. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/repomap.py +0 -0
  116. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/residency.py +0 -0
  117. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/review.py +0 -0
  118. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/sandbox.py +0 -0
  119. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/secretscan.py +0 -0
  120. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/servers/__init__.py +0 -0
  121. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/servers/encord.py +0 -0
  122. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/servers/valohai.py +0 -0
  123. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/servers/xai.py +0 -0
  124. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/sessions.py +0 -0
  125. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/steer.py +0 -0
  126. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/subagents.py +0 -0
  127. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/unreachable.py +0 -0
  128. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/update.py +0 -0
  129. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/vision.py +0 -0
  130. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/src/lcode/web.py +0 -0
  131. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/conftest.py +0 -0
  132. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/fake_lsp_server.py +0 -0
  133. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/fake_mcp_server.py +0 -0
  134. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_acp.py +0 -0
  135. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_action.py +0 -0
  136. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_agent.py +0 -0
  137. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_api.py +0 -0
  138. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_backends.py +0 -0
  139. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_bench.py +0 -0
  140. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_catalog.py +0 -0
  141. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_checkpoints.py +0 -0
  142. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_cli.py +0 -0
  143. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_clipboard.py +0 -0
  144. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_cloud.py +0 -0
  145. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_codesearch.py +0 -0
  146. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_config.py +0 -0
  147. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_context.py +0 -0
  148. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_context_picker.py +0 -0
  149. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_escalate.py +0 -0
  150. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_extensions.py +0 -0
  151. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_gitflow.py +0 -0
  152. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_hooks.py +0 -0
  153. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_jobs.py +0 -0
  154. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_lsp.py +0 -0
  155. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_matching.py +0 -0
  156. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_mcp.py +0 -0
  157. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_memory.py +0 -0
  158. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_model_picker.py +0 -0
  159. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_permissions.py +0 -0
  160. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_plan_mode.py +0 -0
  161. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_repeats.py +0 -0
  162. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_repomap.py +0 -0
  163. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_residency.py +0 -0
  164. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_review.py +0 -0
  165. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_robustness.py +0 -0
  166. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_sandbox.py +0 -0
  167. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_screenshots.py +0 -0
  168. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_secretscan.py +0 -0
  169. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_servers.py +0 -0
  170. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_sessions.py +0 -0
  171. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_steer.py +0 -0
  172. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_subagents.py +0 -0
  173. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_tools.py +0 -0
  174. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_update.py +0 -0
  175. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_vision.py +0 -0
  176. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_web.py +0 -0
  177. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/tests/test_xai.py +0 -0
  178. {lcode_cli-0.37.0 → lcode_cli-0.38.0}/uv.lock +0 -0
@@ -6,6 +6,38 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.38.0] - 2026-10-21
10
+
11
+ ### Added
12
+
13
+ - The screenshot tool can click first. For a Python Qt program, `actions` run before the picture,
14
+ the way a user would: `double_click Camera 1` (a widget by its text) or `double_click 300,200`,
15
+ `click`, `right_click`, `key Escape`, `key ctrl+s`, `type …`, `wait 2`. The result says what each
16
+ step hit. Bugs that only show after a click were out of reach before.
17
+ - Qt's warnings are listed at the top of a screenshot's report, taken from everything the program
18
+ printed. In a real run the cause (`setLayout: … which already has a layout`) was there, among 30
19
+ lines of the program's logs, and the model looked past it.
20
+
21
+ ### Fixed
22
+
23
+ From a real session that "fixed" a double-click feature four times without once looking at the
24
+ window, while the user saw a blank screen each time:
25
+
26
+ - **Windows it never looked at** are sent back once before the answer counts. When a request
27
+ changed a Python file that draws a window (PyQt, PySide, tkinter, Kivy, wx, GTK) and the model
28
+ never took a screenshot or looked at an image, it's told to look (with clicks first, if that's
29
+ where the change shows) or to say plainly that it's unchecked. The real cause was in what Qt
30
+ printed: `setLayout: … which already has a layout`.
31
+ - **A rerun that only took a different time** counts as a repeat: the model ran the same tests four
32
+ times in a row (`75 passed in 0.14s`, then `0.12s`…) without the repeat check noticing.
33
+ - **A reply lcode drops** for going round in circles is saved, and the message says where, so you
34
+ can read what the model was thinking instead of taking the stop on trust.
35
+ - **The checks use the project's own Python.** They ran `python -m …` from PATH, which doesn't have
36
+ the project's dependencies, so a project with a `.venv` "failed" its checks on imports. Now
37
+ `.venv/bin/python` (or `venv`, `env`) runs them.
38
+ - **Tests written for pytest get pytest** when the project has no pytest configuration: the tests
39
+ import it, or a requirements file lists it. They used to get `unittest`, which can't run them.
40
+
9
41
  ## [0.37.0] - 2026-10-20
10
42
 
11
43
  ### Added
@@ -823,7 +855,8 @@ First public release.
823
855
  - `AGENTS.md` project instructions and `/init` to generate them.
824
856
  - One-line installer for Linux and macOS.
825
857
 
826
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.37.0...HEAD
858
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.38.0...HEAD
859
+ [0.38.0]: https://github.com/nasser1941/lcode/compare/v0.37.0...v0.38.0
827
860
  [0.37.0]: https://github.com/nasser1941/lcode/compare/v0.36.0...v0.37.0
828
861
  [0.36.0]: https://github.com/nasser1941/lcode/compare/v0.35.1...v0.36.0
829
862
  [0.35.1]: https://github.com/nasser1941/lcode/compare/v0.35.0...v0.35.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.37.0
3
+ Version: 0.38.0
4
4
  Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
5
5
  Project-URL: Homepage, https://nasser1941.github.io/lcode/
6
6
  Project-URL: Documentation, https://nasser1941.github.io/lcode/
@@ -56,7 +56,8 @@ same tool, the same arguments and the same result.
56
56
  `loop`.
57
57
 
58
58
  A result that changes isn't a repeat, so a test that's run again after a fix, or a background job
59
- that's checked while it runs, never adds up. A call that may have changed something, like an edit or
59
+ that's checked while it runs, never adds up. Timings don't count as a change: `75 passed in 0.12s`
60
+ and then `in 0.14s` is the same result. A call that may have changed something, like an edit or
60
61
  a shell command that isn't read-only, starts the count again for the other calls: checking the build
61
62
  after each edit is fine. Change the threshold with `lcode config set repeat_limit 5`, or turn it off
62
63
  with `0` (that also turns off the checks for unreachable sites and for thinking in circles, below).
@@ -85,7 +86,8 @@ aren't tracked.
85
86
  Small models sometimes think in circles: a few paragraphs, over and over, until the context is full.
86
87
  That takes minutes behind the spinner and ends without an answer. lcode watches the thinking as it
87
88
  streams, and when its sentences keep repeating, it stops the model, drops that reply and asks for a
88
- short one: the next tool call, or what's blocking it. An empty reply, or one that filled the context
89
+ short one: the next tool call, or what's blocking it. The dropped reply is saved, and the message
90
+ says where, so you can read what went round in circles. An empty reply, or one that filled the context
89
91
  before it said anything, gets the same second chance. The second time in a request, lcode stops it
90
92
  (status `loop`), offering the [stronger model](usage.md#a-stronger-model-when-stuck) first when one
91
93
  is set. An answer the context cut off midway is kept, with a warning to `/compact` or use a larger
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.37.0 — local coding agent │
15
+ │ lcode v0.38.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -170,7 +170,9 @@ runs the project's checks itself before the answer is final:
170
170
  - **Programs** using `lcode -p --output json` see the runs in the result's `checks`.
171
171
 
172
172
  **Which checks.** lcode looks at the project: pytest when the configuration mentions it (`uv run
173
- pytest -q` with a `uv.lock`), otherwise `python -m unittest` for `test_*.py` files. Then
173
+ pytest -q` with a `uv.lock`), when the tests import it, or when a requirements file lists it;
174
+ otherwise `python -m unittest` for `test_*.py` files. Python checks run with the project's own
175
+ environment when it has one (`.venv/bin/python`, `venv`, `env`), where its dependencies are. Then
174
176
  `npm test` (or pnpm, yarn, bun) when `package.json` has a test script, `cargo test`, `go test ./...`
175
177
  and `make test`. To choose the command yourself, set it for the repository in
176
178
  `.lcode/settings.toml`. That file is used once you trust the repository, like its hooks:
@@ -183,6 +185,13 @@ command = "uv run pytest -q && uv run ruff check ."
183
185
  Or set it for yourself with `lcode config set checks "make check"`; `lcode config set checks off`
184
186
  turns checks off.
185
187
 
188
+ **Windows it never looked at.** A model that changes a window checks it with an import and the
189
+ project's tests, which don't show what's on the screen, and calls it done: you find a blank
190
+ window. When a request changed a Python file that draws one (it imports PyQt, PySide, tkinter,
191
+ Kivy, wx or GTK) and the model never took a [screenshot](#screenshots) or looked at an image,
192
+ lcode sends it back once: look at it, with clicks first if that's where the change shows, or say
193
+ plainly that it's unchecked. `checks = "off"` turns this off.
194
+
186
195
  **Scripts it never ran.** A model that writes a build or install script tends to call it done
187
196
  without running it once, and the first thing you see is its first error. When a request wrote a
188
197
  shell script (a `.sh` file, or a file with a shell shebang) and no command in it ran the script,
@@ -427,6 +436,20 @@ a screen and sees what you would, measured:
427
436
 
428
437
  - For a web app, the model gives the command that starts the server and the page's URL: lcode
429
438
  starts the server, waits for the page to answer, takes the shot and stops the server.
439
+ - **Clicks and keys first.** For something that only shows after a click, a Python Qt program can
440
+ take `actions` before the picture, the way you would: `double_click 300,200` (a point in the
441
+ window), `double_click Camera 1` (the widget showing that text), `click Save`, `right_click …`,
442
+ `key Escape`, `key ctrl+s`, `type some text`, `wait 2`. The result says what each step hit.
443
+ - **Qt's warnings come first.** Qt names many layout mistakes in what the program prints, often
444
+ among the program's own logs. lcode lists them at the top of the report:
445
+
446
+ ```text
447
+ ● screenshot(python camera_wall.py) actions: double_click Camera 1
448
+ Qt printed warnings (they often name the cause):
449
+ - QWidget::setLayout: Attempting to set QLayout "" on FocusedCamera "", which already has a layout
450
+ Steps, before the picture:
451
+ - double_click Camera 1: on QLabel "Camera 1 — Connected" at (207, 317)
452
+ ```
430
453
  - A model that can see describes the picture with the model's question in mind (see
431
454
  [Which model looks](#images)). Without one, the measurements still come back.
432
455
  - Shots are saved in `.lcode/screenshots/` in the project, which keeps itself out of git, so you
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.37.0"
6
+ __version__ = "0.38.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
@@ -99,7 +99,9 @@ def stall_note(trouble: str) -> str:
99
99
  }[trouble]
100
100
  return (
101
101
  f"[lcode] {first} Don't deliberate at length now. Either make your next tool call, or, if you're stuck, "
102
- "tell the user in a few lines what you did, what fails, and what they could do."
102
+ "tell the user in a few lines what you did, what fails, and what they could do. If you were about to check "
103
+ "something by running it, run it now instead of reasoning about it: for a window, take a screenshot (with "
104
+ "actions for clicks or keys)."
103
105
  )
104
106
 
105
107
 
@@ -961,6 +963,12 @@ class Agent:
961
963
  return []
962
964
  return checks.unrun_scripts(project_root_or_cwd(self.cwd), list(self.tools.before), commands)
963
965
 
966
+ def unseen_windows(self, tools_used: set[str]) -> dict[str, str]:
967
+ """Window code the model changed in this request and never looked at (only where the checks would run)."""
968
+ if self.allowed_tools is not None or self.planning() or self.no_changes or self.settings.checks == "off":
969
+ return {}
970
+ return checks.unseen_windows(project_root_or_cwd(self.cwd), list(self.tools.before), tools_used)
971
+
964
972
  def project_check(self) -> checks.Check | None:
965
973
  """The project's check command, for the main agent outside plan mode and reviews (None otherwise)."""
966
974
  if self.allowed_tools is not None or self.planning() or self.no_changes or self.settings.checks == "off":
@@ -1164,6 +1172,8 @@ class Agent:
1164
1172
  stalls = 0 # replies that went round in circles or ended without a word
1165
1173
  commands: list[str] = [] # shell commands run in this request
1166
1174
  scripts_noted = False # the model was told about scripts it wrote and never ran
1175
+ tools_used: set[str] = set() # tools called in this request
1176
+ windows_noted = False # the model was told about windows it changed and never looked at
1167
1177
  for step in range(self.max_steps):
1168
1178
  if self.cancel is not None and self.cancel.is_set():
1169
1179
  raise KeyboardInterrupt
@@ -1215,10 +1225,11 @@ class Agent:
1215
1225
  continue
1216
1226
  if not calls and (self.reply_trouble == "circles" or not (reply.get("content") or "").strip()):
1217
1227
  stalls += 1
1218
- self.messages.pop() # nothing to keep: no answer, and circles would only fill the context
1228
+ dropped = self.messages.pop() # no answer, and circles would only fill the context
1229
+ kept = self.keep_dropped(dropped)
1219
1230
  if stalls < MAX_STALLS:
1220
1231
  self.console.print(
1221
- Text(f" ⎿ {STALLED[self.reply_trouble]}: asking for a short answer", style="yellow")
1232
+ Text(f" ⎿ {STALLED[self.reply_trouble]}: asking for a short answer{kept}", style="yellow")
1222
1233
  )
1223
1234
  self.messages.append({"role": "user", "content": stall_note(self.reply_trouble)})
1224
1235
  continue
@@ -1234,7 +1245,7 @@ class Agent:
1234
1245
  self.console.print(
1235
1246
  Text(
1236
1247
  "Stopped: the model's replies went round in circles or ended without an answer, twice in "
1237
- "this request. Try again with a smaller step, or tell it what to do differently.",
1248
+ f"this request{kept}. Try again with a smaller step, or tell it what to do differently.",
1238
1249
  style="yellow",
1239
1250
  )
1240
1251
  )
@@ -1253,6 +1264,14 @@ class Agent:
1253
1264
  self.console.print(Text(f" ⎿ never ran {', '.join(unrun)}: asking the model to", style="yellow"))
1254
1265
  self.messages.append({"role": "user", "content": checks.unrun_note(unrun)})
1255
1266
  continue
1267
+ unseen = {} if windows_noted else self.unseen_windows(tools_used)
1268
+ if unseen:
1269
+ windows_noted = True
1270
+ self.console.print(
1271
+ Text(f" ⎿ never looked at {', '.join(unseen)}: asking the model to", style="yellow")
1272
+ )
1273
+ self.messages.append({"role": "user", "content": checks.unseen_note(unseen)})
1274
+ continue
1256
1275
  check = self.project_check() if changed and not verified else None
1257
1276
  if check is not None and check_rounds < checks.MAX_ROUNDS:
1258
1277
  check_rounds += 1
@@ -1345,6 +1364,7 @@ class Agent:
1345
1364
  )
1346
1365
  if out_of_reach.stop:
1347
1366
  blocked = out_of_reach
1367
+ tools_used.add(name)
1348
1368
  if name == "bash":
1349
1369
  commands.append(str(args.get("command", "")))
1350
1370
  if name in ("edit_file", "write_file", "apply_patch") and not result.startswith(REFUSED):
@@ -1412,6 +1432,14 @@ class Agent:
1412
1432
  self.turn_status = "success"
1413
1433
  self.print_stats()
1414
1434
 
1435
+ def keep_dropped(self, reply: dict) -> str:
1436
+ """Save a reply lcode dropped (its thinking too), so the user can see what went round in circles."""
1437
+ text = "\n\n".join(part for part in (reply.get("thinking"), reply.get("content")) if part)
1438
+ if not text.strip():
1439
+ return ""
1440
+ path = context_tools.save_output(text, "dropped reply")
1441
+ return f" (the dropped reply is in {path})" if path else ""
1442
+
1415
1443
  def can_escalate(self) -> bool:
1416
1444
  """Whether a stronger model may be asked here: one is set, and this isn't a subagent or plan mode."""
1417
1445
  model = (self.settings.escalate_model or "").strip().lower()
@@ -50,10 +50,28 @@ def find(root: Path, setting: str, trusted: bool) -> Check | None:
50
50
  return detect(root)
51
51
 
52
52
 
53
- def python() -> str:
53
+ VENVS = (".venv", "venv", "env")
54
+ PYTEST_USE = re.compile(r"^\s*(?:import pytest\b|from pytest\b)|^\s*@pytest\.", re.M)
55
+
56
+
57
+ def python(root: Path | None = None) -> str:
58
+ """The project's own Python (its .venv, venv or env), where its dependencies are; else the one on PATH."""
59
+ if root is not None:
60
+ for env in VENVS:
61
+ for exe in ("bin/python", "Scripts/python.exe"):
62
+ if (root / env / exe).is_file():
63
+ return f"{env}/{exe}" # the checks run in the project's root
54
64
  return "python" if shutil.which("python") else "python3"
55
65
 
56
66
 
67
+ def uses_pytest(root: Path) -> bool:
68
+ """Whether the tests are written for pytest (they import it), or the requirements list it, without a config."""
69
+ for name in ("requirements.txt", "requirements-dev.txt", "requirements_dev.txt", "dev-requirements.txt"):
70
+ if re.search(r"^\s*pytest\b", text(root / name), re.M | re.I):
71
+ return True
72
+ return any(PYTEST_USE.search(text(path)[:20000]) for path in test_files(root)[:50])
73
+
74
+
57
75
  def text(path: Path) -> str:
58
76
  try:
59
77
  return path.read_text(errors="replace") if path.is_file() else ""
@@ -61,38 +79,49 @@ def text(path: Path) -> str:
61
79
  return ""
62
80
 
63
81
 
64
- def python_tests(root: Path) -> bool:
65
- """Whether the project has Python test files (a few levels deep, outside environments and caches)."""
82
+ def test_files(root: Path, limit: int = 200) -> list[Path]:
83
+ """The project's Python test files, a few levels deep, outside environments and caches."""
66
84
  from lcode.tools import IGNORE_DIRS
67
85
 
68
- def walk(folder: Path, depth: int) -> bool:
86
+ found: list[Path] = []
87
+
88
+ def walk(folder: Path, depth: int) -> None:
69
89
  try:
70
- entries = list(folder.iterdir())
90
+ entries = sorted(folder.iterdir())
71
91
  except OSError:
72
- return False
73
- if any(e.is_file() and re.fullmatch(r"test_.*\.py|.*_test\.py", e.name) for e in entries):
74
- return True
75
- return depth > 0 and any(
76
- walk(e, depth - 1)
77
- for e in entries
78
- if e.is_dir() and e.name not in IGNORE_DIRS and not e.name.startswith(".")
79
- )
92
+ return
93
+ found.extend(e for e in entries if e.is_file() and re.fullmatch(r"test_.*\.py|.*_test\.py", e.name))
94
+ if depth > 0:
95
+ for e in entries:
96
+ if len(found) >= limit:
97
+ return
98
+ if e.is_dir() and e.name not in IGNORE_DIRS and e.name not in VENVS and not e.name.startswith("."):
99
+ walk(e, depth - 1)
80
100
 
81
- return walk(root, 3)
101
+ walk(root, 3)
102
+ return found[:limit]
103
+
104
+
105
+ def python_tests(root: Path) -> bool:
106
+ """Whether the project has Python test files (a few levels deep, outside environments and caches)."""
107
+ return bool(test_files(root, limit=1))
82
108
 
83
109
 
84
110
  def detect(root: Path) -> Check | None:
85
111
  """A check command from what the project looks like. Python checks need test files: pytest configured in a
86
112
  project without tests "fails" with nothing to run."""
87
113
  if python_tests(root):
114
+ py = python(root)
88
115
  configs = text(root / "pyproject.toml") + text(root / "setup.cfg") + text(root / "tox.ini")
89
116
  if "pytest" in configs or any((root / name).is_file() for name in ("pytest.ini", "conftest.py")):
90
- runner = "uv run pytest -q" if (root / "uv.lock").is_file() else f"{python()} -m pytest -q"
117
+ runner = "uv run pytest -q" if (root / "uv.lock").is_file() else f"{py} -m pytest -q"
91
118
  return Check(runner, "pytest in the project's configuration")
119
+ if uses_pytest(root):
120
+ return Check(f"{py} -m pytest -q", "tests written for pytest")
92
121
  if list(root.glob("test_*.py")) or list(root.glob("*_test.py")):
93
- return Check(f"{python()} -m unittest", "test files")
122
+ return Check(f"{py} -m unittest", "test files")
94
123
  if (root / "tests").is_dir():
95
- return Check(f"{python()} -m unittest discover -s tests -t .", "tests/")
124
+ return Check(f"{py} -m unittest discover -s tests -t .", "tests/")
96
125
  package = text(root / "package.json")
97
126
  if package:
98
127
  try:
@@ -229,3 +258,52 @@ def unrun_note(scripts: list[str]) -> str:
229
258
  f"deploy, delete data) or needs something you don't have, don't: tell the user plainly that {it} is untested "
230
259
  "and why."
231
260
  )
261
+
262
+
263
+ # ----------------------------------------------------------------------------- windows it never looked at
264
+
265
+ GUI_IMPORT = re.compile(r"^\s*(?:from|import)\s+(PyQt[56]|PySide[26]|tkinter|kivy|wx|gi\.repository)\b", re.M)
266
+ LOOKED = frozenset({"screenshot", "view_image"})
267
+
268
+
269
+ def unseen_windows(root: Path, changed: list[str], tools_used: set[str]) -> dict[str, str]:
270
+ """Python files that draw a window (they import a GUI toolkit), changed in this request when the model never
271
+ looked at one: file -> toolkit.
272
+
273
+ A small model checks a window change by importing the module and running unrelated tests, then calls it done;
274
+ the user finds a blank window. Tests and imports don't show what's on the screen; a screenshot does.
275
+ """
276
+ if tools_used & LOOKED:
277
+ return {}
278
+ found = {}
279
+ for rel in changed:
280
+ path = root / rel
281
+ if path.suffix != ".py" or not path.is_file():
282
+ continue
283
+ try:
284
+ text = path.read_text(errors="replace")[:200_000]
285
+ except OSError:
286
+ continue
287
+ match = GUI_IMPORT.search(text)
288
+ if match:
289
+ found[rel] = match.group(1)
290
+ return found
291
+
292
+
293
+ def unseen_note(found: dict[str, str]) -> str:
294
+ names = ", ".join(found)
295
+ kits = sorted(set(found.values()))
296
+ qt = any(kit.startswith(("PyQt", "PySide")) for kit in kits)
297
+ how = (
298
+ "take a screenshot with the screenshot tool: it runs the program without a screen and shows what it draws, "
299
+ "and what the program printed (Qt names layout mistakes there). For something that only shows after a click "
300
+ 'or a key, give actions, e.g. actions=["double_click Camera 1"]'
301
+ if qt
302
+ else "take a screenshot with the screenshot tool: it runs the program on a virtual display and shows what it "
303
+ "draws"
304
+ )
305
+ return (
306
+ f"[lcode] You changed {names}, which draws a window ({', '.join(kits)}), but never looked at it: tests and "
307
+ f"imports don't show what's on the screen. Before you say it works, {how}. Check the picture against what the "
308
+ "user asked for. If the program can't run here, tell the user plainly that you haven't seen it working."
309
+ )
@@ -6,7 +6,8 @@ one exactly: the same tool, the same arguments and the same result. At the `limi
6
6
  failed: one sooner) the result gets a note that tells the model so, and if it still goes on, lcode ends the
7
7
  request.
8
8
 
9
- Results that change aren't repeats: a test run again after a fix, a background job read while it runs. And
9
+ Results that change aren't repeats: a test run again after a fix, a background job read while it runs. Timings
10
+ don't count as a change, though: `75 passed in 0.12s` and then `in 0.14s` is the same result. And
10
11
  a call that may have changed something (a file edit, a shell command that isn't read-only) starts the count
11
12
  again for every other call, so checking a build after each edit never adds up. Its own repeats still count:
12
13
  running the same failing command over and over is a loop.
@@ -23,6 +24,9 @@ from dataclasses import dataclass
23
24
  from lcode.permissions import is_read_only
24
25
 
25
26
  STOP_AFTER_NOTE = 2 # repeats after the note before the request ends
27
+ TIMING = re.compile(
28
+ r"\b\d+(?:\.\d+)?\s?(?:ms|µs|us|s|secs?|seconds?|mins?|minutes?)\b|\b\d{1,2}:\d{2}:\d{2}(?:[.,]\d+)?\b", re.I
29
+ )
26
30
  FAILED = ("Error:", "A deny rule", "The user denied", "This needs the user's permission")
27
31
  UNCHANGING = frozenset(
28
32
  {
@@ -64,7 +68,7 @@ class Repeats:
64
68
  """Count this call and its result."""
65
69
  if self.limit <= 0:
66
70
  return Verdict(1)
67
- key = hashlib.sha256(json.dumps([name, args, result], sort_keys=True, default=str).encode()).hexdigest()
71
+ key = hashlib.sha256(json.dumps([name, args, steady(result)], sort_keys=True, default=str).encode()).hexdigest()
68
72
  count = self.seen.get(key, 0) + 1
69
73
  if changes_things(name, args, result):
70
74
  self.seen.clear()
@@ -95,6 +99,11 @@ class Repeats:
95
99
  )
96
100
 
97
101
 
102
+ def steady(result: str) -> str:
103
+ """The result without its timings and clock times, which change on every run of the same command."""
104
+ return TIMING.sub("#", result)
105
+
106
+
98
107
  def changes_things(name: str, args: dict, result: str) -> bool:
99
108
  """Whether the call may have changed files (or anything else a later call could see)."""
100
109
  if name in UNCHANGING: