lcode-cli 0.35.1__tar.gz → 0.36.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 (175) hide show
  1. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/CHANGELOG.md +13 -1
  2. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/PKG-INFO +1 -1
  3. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/models.md +14 -1
  4. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/quickstart.md +1 -1
  5. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/usage.md +1 -1
  6. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/__init__.py +1 -1
  7. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/cli.py +81 -21
  8. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/repl.py +80 -31
  9. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_backends.py +14 -0
  10. lcode_cli-0.36.0/tests/test_model_picker.py +94 -0
  11. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_residency.py +2 -2
  12. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/.editorconfig +0 -0
  13. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/.github/CODEOWNERS +0 -0
  14. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  15. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  16. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  17. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  18. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  19. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/.github/dependabot.yml +0 -0
  20. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/.github/workflows/ci.yml +0 -0
  21. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/.github/workflows/docs.yml +0 -0
  22. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/.github/workflows/release.yml +0 -0
  23. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/.gitignore +0 -0
  24. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/.pre-commit-config.yaml +0 -0
  25. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/AGENTS.md +0 -0
  26. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/CODE_OF_CONDUCT.md +0 -0
  27. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/CONTRIBUTING.md +0 -0
  28. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/LICENSE +0 -0
  29. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/README.md +0 -0
  30. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/SECURITY.md +0 -0
  31. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/action.yml +0 -0
  32. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/agents.md +0 -0
  33. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/assets/demo.svg +0 -0
  34. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/assets/extra.css +0 -0
  35. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/automation.md +0 -0
  36. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/bench.md +0 -0
  37. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/changelog.md +0 -0
  38. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/commands.md +0 -0
  39. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/configuration.md +0 -0
  40. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/contributing.md +0 -0
  41. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/editors.md +0 -0
  42. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/git.md +0 -0
  43. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/hooks.md +0 -0
  44. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/how-it-works.md +0 -0
  45. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/index.md +0 -0
  46. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/installation.md +0 -0
  47. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/lsp.md +0 -0
  48. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/mcp.md +0 -0
  49. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/memory.md +0 -0
  50. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/sandbox.md +0 -0
  51. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/search.md +0 -0
  52. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/servers.md +0 -0
  53. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/docs/troubleshooting.md +0 -0
  54. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/examples/README.md +0 -0
  55. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/examples/api/fix_tests.py +0 -0
  56. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/examples/commands/review.md +0 -0
  57. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/examples/skills/commit-message/SKILL.md +0 -0
  58. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
  59. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/install.sh +0 -0
  60. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/mkdocs.yml +0 -0
  61. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/overrides/main.html +0 -0
  62. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/pyproject.toml +0 -0
  63. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/scripts/make_logos.py +0 -0
  64. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/scripts/record_demo.py +0 -0
  65. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/scripts/social-preview.html +0 -0
  66. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/__main__.py +0 -0
  67. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/acp.py +0 -0
  68. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/action.py +0 -0
  69. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/agent.py +0 -0
  70. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/api.py +0 -0
  71. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/backends.py +0 -0
  72. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/bench.py +0 -0
  73. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/bench_backend.py +0 -0
  74. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/bench_runtime.py +0 -0
  75. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/catalog.py +0 -0
  76. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/checkpoints.py +0 -0
  77. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/checks.py +0 -0
  78. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/clipboard.py +0 -0
  79. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/codesearch.py +0 -0
  80. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/config.py +0 -0
  81. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/context.py +0 -0
  82. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/escalate.py +0 -0
  83. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/extensions.py +0 -0
  84. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/frontmatter.py +0 -0
  85. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/gitflow.py +0 -0
  86. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/hardware.py +0 -0
  87. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/hooks.py +0 -0
  88. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/jobs.py +0 -0
  89. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/limits.py +0 -0
  90. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/lsp.py +0 -0
  91. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/matching.py +0 -0
  92. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/mcp/__init__.py +0 -0
  93. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/mcp/auth.py +0 -0
  94. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/mcp/catalog.py +0 -0
  95. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/mcp/catalog.toml +0 -0
  96. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/mcp/client.py +0 -0
  97. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/mcp/commands.py +0 -0
  98. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/mcp/config.py +0 -0
  99. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/mcp/http.py +0 -0
  100. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/mcp/manager.py +0 -0
  101. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/mcp/protocol.py +0 -0
  102. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/mcp/server.py +0 -0
  103. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/mcp/stdio.py +0 -0
  104. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/memory.py +0 -0
  105. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/models.toml +0 -0
  106. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/notify.py +0 -0
  107. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/ollama.py +0 -0
  108. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/permissions.py +0 -0
  109. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/planning.py +0 -0
  110. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/render.py +0 -0
  111. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/repeats.py +0 -0
  112. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/repomap.py +0 -0
  113. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/residency.py +0 -0
  114. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/review.py +0 -0
  115. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/sandbox.py +0 -0
  116. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/screenshots.py +0 -0
  117. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/secretscan.py +0 -0
  118. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/servers/__init__.py +0 -0
  119. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/servers/encord.py +0 -0
  120. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/servers/valohai.py +0 -0
  121. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/servers/xai.py +0 -0
  122. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/sessions.py +0 -0
  123. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/steer.py +0 -0
  124. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/subagents.py +0 -0
  125. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/tools.py +0 -0
  126. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/unreachable.py +0 -0
  127. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/update.py +0 -0
  128. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/vision.py +0 -0
  129. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/src/lcode/web.py +0 -0
  130. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/conftest.py +0 -0
  131. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/fake_lsp_server.py +0 -0
  132. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/fake_mcp_server.py +0 -0
  133. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_acp.py +0 -0
  134. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_action.py +0 -0
  135. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_agent.py +0 -0
  136. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_api.py +0 -0
  137. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_bench.py +0 -0
  138. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_catalog.py +0 -0
  139. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_checkpoints.py +0 -0
  140. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_checks.py +0 -0
  141. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_cli.py +0 -0
  142. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_clipboard.py +0 -0
  143. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_codesearch.py +0 -0
  144. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_config.py +0 -0
  145. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_context.py +0 -0
  146. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_context_picker.py +0 -0
  147. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_escalate.py +0 -0
  148. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_extensions.py +0 -0
  149. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_gitflow.py +0 -0
  150. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_hooks.py +0 -0
  151. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_jobs.py +0 -0
  152. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_lsp.py +0 -0
  153. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_matching.py +0 -0
  154. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_mcp.py +0 -0
  155. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_memory.py +0 -0
  156. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_permissions.py +0 -0
  157. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_plan_mode.py +0 -0
  158. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_repeats.py +0 -0
  159. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_repomap.py +0 -0
  160. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_review.py +0 -0
  161. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_robustness.py +0 -0
  162. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_sandbox.py +0 -0
  163. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_screenshots.py +0 -0
  164. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_secretscan.py +0 -0
  165. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_servers.py +0 -0
  166. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_sessions.py +0 -0
  167. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_steer.py +0 -0
  168. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_stuck.py +0 -0
  169. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_subagents.py +0 -0
  170. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_tools.py +0 -0
  171. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_update.py +0 -0
  172. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_vision.py +0 -0
  173. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_web.py +0 -0
  174. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/tests/test_xai.py +0 -0
  175. {lcode_cli-0.35.1 → lcode_cli-0.36.0}/uv.lock +0 -0
@@ -6,6 +6,17 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.36.0] - 2026-10-18
10
+
11
+ ### Changed
12
+
13
+ - `/models` and `/model` show a numbered list of models, and you switch by typing a number: no
14
+ second `/model <name>`. `/model 2` switches straight away, and a name still works. The list ends
15
+ with the other Ollama models you've installed that can call tools, and a model that isn't
16
+ installed says how to get it.
17
+ - `/context` says why it stops at the model's largest window (256K for qwen3.6-35b): Ollama caps the
18
+ window at what the model was trained on, whatever the memory, so 512K or 1M would only look larger.
19
+
9
20
  ## [0.35.1] - 2026-10-15
10
21
 
11
22
  ### Fixed
@@ -799,7 +810,8 @@ First public release.
799
810
  - `AGENTS.md` project instructions and `/init` to generate them.
800
811
  - One-line installer for Linux and macOS.
801
812
 
802
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.35.1...HEAD
813
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.36.0...HEAD
814
+ [0.36.0]: https://github.com/nasser1941/lcode/compare/v0.35.1...v0.36.0
803
815
  [0.35.1]: https://github.com/nasser1941/lcode/compare/v0.35.0...v0.35.1
804
816
  [0.35.0]: https://github.com/nasser1941/lcode/compare/v0.34.0...v0.35.0
805
817
  [0.34.0]: https://github.com/nasser1941/lcode/compare/v0.33.1...v0.34.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.35.1
3
+ Version: 0.36.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/
@@ -60,9 +60,18 @@ when they fit entirely in GPU (or Apple unified) memory.
60
60
 
61
61
  ```text
62
62
  ❯ /models
63
- ❯ /model qwen3.5-9b
63
+ Models for this machine: RTX 4080 Laptop (12 GB VRAM), 31 GB RAM
64
+ # Key Size Max ctx Fits here Speed Status
65
+ 1 qwen3.6-35b 22.6 GB 256K 256K good (experts in RAM) installed, recommended, current
66
+ 2 qwen3.8-27b …
67
+ 9 my-model:7b installed, not in catalog
68
+ Switch to which model? (number or name, Enter to keep lcode-qwen3.6-35b): 9
64
69
  ```
65
70
 
71
+ `/model` shows the same list. `/model 9` or `/model qwen3.5-9b` switches straight away. The
72
+ list ends with the other Ollama models you've installed that can call tools. A model that isn't
73
+ installed yet says how to get it (`lcode setup <key>` in a shell).
74
+
66
75
  ### Any other Ollama model
67
76
 
68
77
  ```bash
@@ -107,6 +116,10 @@ Context window for lcode-qwen3.6-35b
107
116
  Choose a number, a size like 96k, or press Enter to keep 256K: 2
108
117
  ```
109
118
 
119
+ The list stops at the largest window the model was trained on, and says so: Ollama caps the window
120
+ there whatever the memory, so a larger setting would only look larger. For qwen3.6-35b that's 256K;
121
+ 512K and 1M are offered for models trained on them.
122
+
110
123
  Sizes accept `k` and `m` suffixes (`128k` = 131,072 tokens, `1m` = 1,048,576). When the window is
111
124
  85% full, lcode summarizes the conversation automatically so you can keep working; `/compact` does it
112
125
  on demand.
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.35.1 — local coding agent │
15
+ │ lcode v0.36.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -76,7 +76,7 @@ reasoning is on.
76
76
  | `/plan [request]` | Work out a [plan](#plan-mode) before changing anything, or show the approved plan |
77
77
  | `/compact [focus]` | Summarize the conversation to free context |
78
78
  | `/context [size]` | Show how full the context window is, what's using it (by category), and change its size: pick from a list with memory estimates, or give a size like `/context 128k` (`/ctx` is a shortcut) |
79
- | `/model [name]`, `/models` | Switch model, list models |
79
+ | `/models`, `/model [number or name]` | List the models and how they fit this machine, numbered: type a number to switch, or `/model 2`, `/model qwen3.5-9b` |
80
80
  | `/think [on\|off]` | Toggle reasoning |
81
81
  | `/verbose` | Toggle showing the reasoning text |
82
82
  | `/mode [ask\|plan\|auto-edit\|yolo]` | Set the permission mode |
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.35.1"
6
+ __version__ = "0.36.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
@@ -113,44 +113,96 @@ def choose_context(
113
113
  # ----------------------------------------------------------------------------- lcode models
114
114
 
115
115
 
116
- def print_served(client: backends.OpenAICompatible, current: str | None) -> None:
116
+ def print_served(
117
+ client: backends.OpenAICompatible, current: str | None, numbered: bool = False, out: Console | None = None
118
+ ) -> list[str]:
119
+ out = out or console
117
120
  try:
118
121
  served = client.chat_models()
119
122
  current = backends.pick_model(client, current) if current else None
120
123
  except OllamaError as e:
121
124
  if not isinstance(e, backends.BackendError) or "doesn't serve" not in str(e):
122
- console.print(f"[red]{e}[/]")
123
- return
124
- console.print(f"[bold]Models served by {escape(client.describe())}[/]")
125
- for name in served:
126
- console.print(f" {escape(name)}" + (" [bold]current[/]" if name == current else ""))
125
+ out.print(f"[red]{e}[/]")
126
+ return []
127
+ served = []
128
+ out.print(f"[bold]Models served by {escape(client.describe())}[/]")
129
+ for i, name in enumerate(served, 1):
130
+ number = f"[cyan]{i:>3}[/] " if numbered else " "
131
+ out.print(f"{number}{escape(name)}" + (" [bold]current[/]" if name == current else ""))
127
132
  if not served:
128
- console.print(" none listed")
129
- console.print(
130
- "[dim]Models are downloaded and loaded in the server itself. Choose one with [/]lcode --model <id>[dim] "
131
- "or [/]lcode config set model <id>[dim].[/]"
132
- )
133
+ out.print(" none listed")
134
+ if not numbered:
135
+ out.print(
136
+ "[dim]Models are downloaded and loaded in the server itself. Choose one with [/]lcode --model <id>[dim] "
137
+ "or [/]lcode config set model <id>[dim].[/]"
138
+ )
139
+ return served
133
140
 
134
141
 
135
- def print_models(ollama: Ollama | None, hw: Hardware, current: str | None = None) -> None:
142
+ def other_installed(ollama: Ollama, installed: set[str]) -> list[str]:
143
+ """Installed Ollama models outside the catalog that can chat and call tools (so lcode can use them), by name.
144
+ Embedding models are left out: Ollama may list `tools` for them too, but not `completion`."""
145
+ known = {name for spec in catalog.load() for name in (spec.tag, spec.local_name)}
146
+ usable = []
147
+ for name in sorted({n.removesuffix(":latest") for n in installed} - known):
148
+ try:
149
+ capabilities = ollama.show(name).get("capabilities") or []
150
+ if "completion" in capabilities and "tools" in capabilities:
151
+ usable.append(name)
152
+ except OllamaError:
153
+ continue
154
+ return usable
155
+
156
+
157
+ def model_choices(ollama: Ollama | None) -> list[str]:
158
+ """What `/models` numbers, in its order: the catalog's keys, then other installed models that call tools.
159
+ For an OpenAI-compatible server, the models it serves."""
136
160
  if ollama is not None and backends.is_openai(ollama):
137
- print_served(ollama, current) # type: ignore[arg-type]
138
- return
161
+ try:
162
+ return ollama.chat_models() # type: ignore[union-attr]
163
+ except OllamaError:
164
+ return []
165
+ try:
166
+ installed = ollama.installed_names() if ollama else set()
167
+ except OllamaError:
168
+ installed = set()
169
+ return [spec.key for spec in catalog.load()] + (other_installed(ollama, installed) if ollama else [])
170
+
171
+
172
+ def print_models(
173
+ ollama: Ollama | None,
174
+ hw: Hardware,
175
+ current: str | None = None,
176
+ numbered: bool = False,
177
+ out: Console | None = None,
178
+ ) -> list[str]:
179
+ """The models table; with `numbered`, a number on each row to pick it by. Returns the rows' names in order."""
180
+ out = out or console
181
+ if ollama is not None and backends.is_openai(ollama):
182
+ return print_served(ollama, current, numbered, out) # type: ignore[arg-type]
139
183
  try:
140
184
  installed = ollama.installed_names() if ollama else set()
141
185
  except OllamaError:
142
186
  installed = set()
143
187
  rec = catalog.recommend(hw)
144
- wide = console.width >= 130 # the descriptive columns don't fit an 80-column terminal
188
+ wide = out.width >= 130 # the descriptive columns don't fit an 80-column terminal
145
189
  columns = ["Key", "Model", "Type", "Size", "Max ctx", "Fits here", "Speed", "Status"]
146
190
  if not wide:
147
191
  columns = [c for c in columns if c not in ("Model", "Type")]
192
+ if numbered:
193
+ columns.insert(0, "#")
148
194
  table = Table(title=f"Models for this machine: {hw.describe()}", title_justify="left", header_style="bold")
149
195
  for col in columns:
150
196
  table.add_column(
151
- col, no_wrap=col in ("Key", "Size", "Max ctx", "Fits here"), min_width=11 if col == "Status" else None
197
+ col,
198
+ no_wrap=col in ("#", "Key", "Size", "Max ctx", "Fits here"),
199
+ min_width=11 if col == "Status" else None,
200
+ justify="right" if col == "#" else "left",
201
+ style="cyan" if col == "#" else None,
152
202
  )
203
+ choices: list[str] = []
153
204
  for spec in catalog.load():
205
+ choices.append(spec.key)
154
206
  ctx, speed = spec.fit(hw)
155
207
  marks = []
156
208
  if spec.local_name in installed or spec.tag in installed:
@@ -161,6 +213,7 @@ def print_models(ollama: Ollama | None, hw: Hardware, current: str | None = None
161
213
  marks.append("[bold]current[/]")
162
214
  marks.append("tested" if spec.tested else "[dim]untested[/]")
163
215
  row = {
216
+ "#": str(len(choices)),
164
217
  "Key": spec.key,
165
218
  "Model": spec.name,
166
219
  "Type": spec.params,
@@ -171,11 +224,18 @@ def print_models(ollama: Ollama | None, hw: Hardware, current: str | None = None
171
224
  "Status": ", ".join(marks) if wide else "\n".join(marks),
172
225
  }
173
226
  table.add_row(*(row[c] for c in columns))
174
- console.print(table)
175
- console.print(
176
- "[dim]Install one with [/]lcode setup <key>[dim]. 'Fits here' is the largest context that fits in memory; "
177
- "choose another with --context. Any other Ollama model with tool calling also works: lcode --model <tag>[/]"
178
- )
227
+ for name in other_installed(ollama, installed) if ollama else []:
228
+ choices.append(name)
229
+ marks = ["[green]installed[/]"] + (["[bold]current[/]"] if name == current else []) + ["[dim]not in catalog[/]"]
230
+ row = {"#": str(len(choices)), "Key": escape(name), "Status": ", ".join(marks) if wide else "\n".join(marks)}
231
+ table.add_row(*(row.get(c, "") for c in columns))
232
+ out.print(table)
233
+ if not numbered:
234
+ out.print(
235
+ "[dim]Install one with [/]lcode setup <key>[dim]. 'Fits here' is the largest context that fits in memory; "
236
+ "choose another with --context. Any other Ollama model with tool calling also works: lcode --model <tag>[/]"
237
+ )
238
+ return choices
179
239
 
180
240
 
181
241
  def cmd_models(args) -> None:
@@ -68,8 +68,8 @@ COMMANDS = {
68
68
  "/compact": "Summarize the conversation to free context (optional: what to focus on)",
69
69
  "/context": "Show context usage and change the window size: pick from a list, or /context 128k",
70
70
  "/ctx": "Shortcut for /context",
71
- "/model": "Show or switch model, e.g. /model qwen3.5-9b",
72
- "/models": "List models and how they fit this machine",
71
+ "/model": "Switch model: pick a number from the list, or /model 2, /model qwen3.5-9b",
72
+ "/models": "List models and how they fit this machine; type a number to switch",
73
73
  "/think": "Toggle model reasoning on/off",
74
74
  "/verbose": "Toggle showing the model's reasoning text",
75
75
  "/plan": "Plan before changing anything: /plan <request>, or /plan to show the approved plan",
@@ -243,14 +243,20 @@ def banner(agent: Agent) -> None:
243
243
  CONTEXT_CHOICES = (16384, 32768, 65536, 131072, 262144, 524288, 1048576)
244
244
 
245
245
 
246
+ def model_limit(agent: Agent) -> int | None:
247
+ """The largest context the model supports (what Ollama reports, else the catalog's), if known."""
248
+ spec = catalog.find(agent.settings.model)
249
+ try:
250
+ return agent.ollama.max_context(agent.settings.model) or (spec.max_context if spec else None)
251
+ except OllamaError:
252
+ return spec.max_context if spec else None
253
+
254
+
246
255
  def context_options(agent: Agent, hardware: Hardware) -> list[dict]:
247
256
  """The context sizes this model supports, with how each one fits this machine."""
248
257
  s = agent.settings
249
258
  spec = catalog.find(s.model)
250
- try:
251
- max_ctx = agent.ollama.max_context(s.model) or (spec.max_context if spec else None)
252
- except OllamaError:
253
- max_ctx = spec.max_context if spec else None
259
+ max_ctx = model_limit(agent)
254
260
  sizes = {c for c in CONTEXT_CHOICES if not max_ctx or c <= max_ctx} | {s.context}
255
261
  if max_ctx:
256
262
  sizes.add(max_ctx)
@@ -317,6 +323,15 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
317
323
  marks.append("[cyan]recommended[/]")
318
324
  table.add_row(str(i), format_tokens(option["size"]), option["note"], ", ".join(marks))
319
325
  c.print(table)
326
+ limit = model_limit(agent)
327
+ beyond = [size for size in CONTEXT_CHOICES if limit and size > limit]
328
+ if beyond:
329
+ sizes = " and ".join(format_tokens(size) for size in beyond[:2])
330
+ c.print(
331
+ f"[dim]Not offered: {sizes}{' and more' if len(beyond) > 2 else ''}. {s.model} was trained on up to "
332
+ f"{format_tokens(limit)} tokens, and Ollama caps the window there whatever the memory, so a larger "
333
+ "setting would only look larger.[/]"
334
+ )
320
335
  try:
321
336
  answer = input(f" Choose a number, a size like 96k, or press Enter to keep {format_tokens(s.context)}: ")
322
337
  except EOFError:
@@ -809,6 +824,63 @@ def restore_checkpoint(agent: Agent, target: Checkpoint, rewind: bool) -> None:
809
824
  agent.save()
810
825
 
811
826
 
827
+ def model_command(agent: Agent, arg: str, hardware: Hardware) -> None:
828
+ """/models and /model: the numbered list to pick from, or straight to a number or a name (/model 2)."""
829
+ from lcode.cli import model_choices, print_models
830
+
831
+ c, s = agent.console, agent.settings
832
+ if arg and not arg.isdigit():
833
+ switch_model(agent, arg, hardware)
834
+ return
835
+ if arg:
836
+ choices = model_choices(agent.ollama)
837
+ else:
838
+ choices = print_models(agent.ollama, hardware, s.model, numbered=True, out=c)
839
+ if not choices:
840
+ return
841
+ try:
842
+ answer = input(f" Switch to which model? (number or name, Enter to keep {s.model}): ").strip()
843
+ except EOFError:
844
+ return
845
+ if not answer:
846
+ c.print(f"Keeping {s.model}.")
847
+ return
848
+ arg = answer
849
+ if arg.isdigit():
850
+ if not 1 <= int(arg) <= len(choices):
851
+ c.print(f"[red]There's no model {arg} in the list: choose 1 to {len(choices)}.[/]")
852
+ return
853
+ arg = choices[int(arg) - 1]
854
+ switch_model(agent, arg, hardware)
855
+
856
+
857
+ def switch_model(agent: Agent, wanted: str, hardware: Hardware) -> None:
858
+ """Switch to a catalog key or an installed model, and size the context for it."""
859
+ from lcode.cli import NotInstalled, resolve_model
860
+
861
+ c, s = agent.console, agent.settings
862
+ try:
863
+ name, spec = resolve_model(agent.ollama, wanted)
864
+ except (NotInstalled, OllamaError) as e:
865
+ c.print(f"[red]{e}[/]")
866
+ return
867
+ if name == s.model:
868
+ c.print(f"Already using {name} (context {format_tokens(s.context)}).")
869
+ return
870
+ s.model = name
871
+ s.num_batch = spec.num_batch if spec and name == spec.local_name else None
872
+ if spec: # size the context for the new model: largest window that fits this machine
873
+ s.context = limits.cap(name, spec.fit(hardware)[0] or MIN_USEFUL_CONTEXT)
874
+ elif backends.is_openai(agent.ollama):
875
+ s.context = backends.context_for(agent.ollama, name, None)[0] # the server's window
876
+ agent._vision = False # the model that looks at images goes with the model: decide again
877
+ agent.messages[0]["content"] = agent.system_prompt()
878
+ c.print(f"[green]Switched to {name}[/] (context {format_tokens(s.context)}).")
879
+ warning = residency.dense_warning(agent.ollama, name, s.context, hardware)
880
+ if warning:
881
+ agent.say(warning, warning=True)
882
+
883
+
812
884
  def apply_context(agent: Agent, size: int, hardware: Hardware) -> None:
813
885
  c, s = agent.console, agent.settings
814
886
  if size == s.context:
@@ -956,8 +1028,6 @@ def session_start(agent: Agent) -> None:
956
1028
 
957
1029
  def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
958
1030
  """Run a slash command. Returns False when the REPL should exit."""
959
- from lcode.cli import NotInstalled, print_models, resolve_model
960
-
961
1031
  cmd, _, arg = line.partition(" ")
962
1032
  arg = arg.strip()
963
1033
  c, s = agent.console, agent.settings
@@ -1006,29 +1076,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
1006
1076
  agent.compact(arg)
1007
1077
  elif cmd in ("/context", "/ctx"):
1008
1078
  context_command(agent, arg, hardware)
1009
- elif cmd == "/models":
1010
- print_models(agent.ollama, hardware, s.model)
1011
- elif cmd == "/model":
1012
- if not arg:
1013
- c.print(f"Model: {s.model}. Switch with /model <name>; list with /models.")
1014
- else:
1015
- try:
1016
- name, spec = resolve_model(agent.ollama, arg)
1017
- except (NotInstalled, OllamaError) as e:
1018
- c.print(f"[red]{e}[/]")
1019
- return True
1020
- s.model = name
1021
- s.num_batch = spec.num_batch if spec and name == spec.local_name else None
1022
- if spec: # size the context for the new model: largest window that fits this machine
1023
- s.context = limits.cap(name, spec.fit(hardware)[0] or MIN_USEFUL_CONTEXT)
1024
- elif backends.is_openai(agent.ollama):
1025
- s.context = backends.context_for(agent.ollama, name, None)[0] # the server's window
1026
- agent._vision = False # the model that looks at images goes with the model: decide again
1027
- agent.messages[0]["content"] = agent.system_prompt()
1028
- c.print(f"[green]Switched to {name}[/] (context {format_tokens(s.context)}).")
1029
- warning = residency.dense_warning(agent.ollama, name, s.context, hardware)
1030
- if warning:
1031
- agent.say(warning, warning=True)
1079
+ elif cmd in ("/models", "/model"):
1080
+ model_command(agent, arg, hardware)
1032
1081
  elif cmd == "/think":
1033
1082
  s.think = (arg == "on") if arg in ("on", "off") else not s.think
1034
1083
  c.print(f"Reasoning {'on' if s.think else 'off'}.")
@@ -347,3 +347,17 @@ def test_a_reply_cut_off_by_the_server_says_so(server):
347
347
  assert chunks[-1]["done_reason"] == "length"
348
348
  FakeServer.replies = [sse(delta(content="Done."), USAGE)]
349
349
  assert list(OpenAICompatible(server).chat_stream({"model": "m", "messages": []}))[-1]["done_reason"] == "stop"
350
+
351
+
352
+ def test_served_models_are_numbered_to_pick_from(server):
353
+ import io
354
+
355
+ from rich.console import Console
356
+
357
+ from lcode import cli
358
+
359
+ client = OpenAICompatible(server)
360
+ out = Console(file=io.StringIO(), width=120)
361
+ names = cli.print_models(client, None, MODELS[0], numbered=True, out=out)
362
+ assert names == cli.model_choices(client) == ["google/gemma-4-12b", "qwen/qwen3.6-35b-a3b"] # no embeddings
363
+ assert " 2 qwen/qwen3.6-35b-a3b current" in out.file.getvalue()
@@ -0,0 +1,94 @@
1
+ """/models and /model: a numbered list to switch from, without typing the model's name."""
2
+
3
+ import pytest
4
+
5
+ from conftest import output
6
+ from lcode import catalog, cli
7
+ from lcode.hardware import Hardware
8
+ from lcode.repl import handle_command
9
+
10
+ LAPTOP = Hardware("linux", "x", 31, "RTX 4080 Laptop", 12)
11
+
12
+
13
+ def answer(monkeypatch, text):
14
+ asked = []
15
+
16
+ def ask(prompt):
17
+ asked.append(prompt)
18
+ return text
19
+
20
+ monkeypatch.setattr("builtins.input", ask)
21
+ return asked
22
+
23
+
24
+ def number_of(name: str) -> str:
25
+ keys = [spec.key for spec in catalog.load()]
26
+ return str(keys.index(name) + 1) if name in keys else str(len(keys) + 1) # custom:7b comes after the catalog
27
+
28
+
29
+ def test_models_lists_numbers_and_switches_to_the_one_picked(agent, monkeypatch):
30
+ asked = answer(monkeypatch, number_of("custom:7b"))
31
+ handle_command(agent, "/models", LAPTOP)
32
+ out = output(agent)
33
+ assert "Switch to which model? (number or name, Enter to keep lcode-qwen3.6-35b)" in asked[0]
34
+ [row] = [line for line in out.splitlines() if "custom:7b" in line and "│" in line]
35
+ assert row.split("│")[1].strip() == number_of("custom:7b") and "catalog" in out
36
+ assert agent.settings.model == "custom:7b" and "Switched to custom:7b" in output(agent)
37
+
38
+
39
+ def test_model_with_no_name_shows_the_same_list(agent, monkeypatch):
40
+ answer(monkeypatch, "")
41
+ handle_command(agent, "/model", LAPTOP)
42
+ assert "Models for this machine" in output(agent) and "Keeping lcode-qwen3.6-35b." in output(agent)
43
+ assert agent.settings.model == "lcode-qwen3.6-35b"
44
+
45
+
46
+ def test_a_number_or_a_name_switches_without_the_list(agent, monkeypatch):
47
+ monkeypatch.setattr("builtins.input", lambda _: pytest.fail("should not ask"))
48
+ handle_command(agent, f"/model {number_of('custom:7b')}", LAPTOP)
49
+ assert agent.settings.model == "custom:7b" and "Models for this machine" not in output(agent)
50
+ handle_command(agent, "/model qwen3.6-35b", LAPTOP)
51
+ assert agent.settings.model == "lcode-qwen3.6-35b" and agent.settings.context == 262144
52
+ handle_command(agent, "/models 1", LAPTOP)
53
+ assert "Already using lcode-qwen3.6-35b" in output(agent)
54
+
55
+
56
+ def test_a_model_that_isnt_installed_says_how_to_get_it(agent, monkeypatch):
57
+ answer(monkeypatch, number_of("qwen3.8-27b"))
58
+ handle_command(agent, "/models", LAPTOP)
59
+ assert "lcode setup qwen3.8-27b" in output(agent) and agent.settings.model == "lcode-qwen3.6-35b"
60
+ handle_command(agent, "/model 99", LAPTOP)
61
+ assert "There's no model 99 in the list" in " ".join(output(agent).split())
62
+
63
+
64
+ def test_installed_models_that_cant_call_tools_are_left_out(agent):
65
+ installed = {"lcode-qwen3.6-35b", "custom:7b", "embed:1", "embed:1:latest", "chat-only:3b"}
66
+ agent.ollama.installed_names = lambda: installed
67
+ agent.ollama.capabilities["embed:1"] = ["tools", "thinking", "embedding"] # what Ollama says of qwen3-embedding
68
+ agent.ollama.capabilities["chat-only:3b"] = ["completion"]
69
+ choices = cli.model_choices(agent.ollama)
70
+ assert choices[-1] == "custom:7b" and "embed:1" not in choices and "chat-only:3b" not in choices
71
+ assert choices[: len(catalog.load())] == [spec.key for spec in catalog.load()]
72
+
73
+
74
+ def test_the_shell_command_keeps_its_table_without_numbers(monkeypatch):
75
+ import io
76
+
77
+ from rich.console import Console
78
+
79
+ from conftest import FakeOllama
80
+
81
+ wide = Console(file=io.StringIO(), width=160)
82
+ names = cli.print_models(FakeOllama(), LAPTOP, "qwen3.6-35b", out=wide)
83
+ out = wide.file.getvalue()
84
+ assert names[0] == "qwen3.6-35b" and "Install one with" in out and " # " not in out.splitlines()[2]
85
+
86
+
87
+ def test_context_says_why_larger_windows_are_not_offered(agent, monkeypatch):
88
+ answer(monkeypatch, "")
89
+ handle_command(agent, "/context", LAPTOP)
90
+ text = " ".join(output(agent).split())
91
+ assert "Not offered: 512K and 1M. lcode-qwen3.6-35b was trained on up to 256K tokens" in text
92
+ agent.ollama.max_ctx = 1048576
93
+ handle_command(agent, "/context", LAPTOP)
94
+ assert " ".join(output(agent).split()).count("Not offered") == 1
@@ -159,6 +159,6 @@ def test_switching_models_picks_the_vision_model_again(make_agent, monkeypatch):
159
159
 
160
160
  agent = make_agent()
161
161
  agent._vision = "qwen3.8:27b" # decided for the previous model
162
- monkeypatch.setattr("lcode.cli.resolve_model", lambda ollama, name: ("lcode-qwen3.6-35b", None))
163
- handle_command(agent, "/model qwen3.6-35b", LAPTOP)
162
+ monkeypatch.setattr("lcode.cli.resolve_model", lambda ollama, name: ("custom:7b", None))
163
+ handle_command(agent, "/model custom:7b", LAPTOP)
164
164
  assert agent._vision != "qwen3.8:27b" # decided again for the new model (here: none can see)
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