lcode-cli 0.42.0__tar.gz → 0.43.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 (198) hide show
  1. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/CHANGELOG.md +16 -1
  2. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/PKG-INFO +1 -1
  3. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/modes.md +40 -5
  4. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/quickstart.md +1 -1
  5. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/usage.md +1 -0
  6. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/__init__.py +1 -1
  7. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/agent.py +4 -2
  8. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/cli.py +6 -2
  9. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/comfy.py +3 -2
  10. lcode_cli-0.43.0/src/lcode/edits.py +283 -0
  11. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/media.py +247 -37
  12. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/media.toml +37 -2
  13. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/media_catalog.py +12 -0
  14. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/repl.py +107 -37
  15. lcode_cli-0.43.0/src/lcode/workflows/remove-background.json +13 -0
  16. lcode_cli-0.43.0/src/lcode/workflows/upscale-esrgan.json +12 -0
  17. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/comfy_standin.py +16 -1
  18. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_comfy.py +1 -1
  19. lcode_cli-0.43.0/tests/test_image_edit.py +214 -0
  20. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_media_catalog.py +12 -3
  21. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.editorconfig +0 -0
  22. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/CODEOWNERS +0 -0
  23. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  24. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  25. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  26. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  27. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  28. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/dependabot.yml +0 -0
  29. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/workflows/ci.yml +0 -0
  30. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/workflows/docs.yml +0 -0
  31. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/workflows/release.yml +0 -0
  32. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.gitignore +0 -0
  33. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.pre-commit-config.yaml +0 -0
  34. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/AGENTS.md +0 -0
  35. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/CODE_OF_CONDUCT.md +0 -0
  36. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/CONTRIBUTING.md +0 -0
  37. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/LICENSE +0 -0
  38. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/README.md +0 -0
  39. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/SECURITY.md +0 -0
  40. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/action.yml +0 -0
  41. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/agents.md +0 -0
  42. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/assets/demo.svg +0 -0
  43. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/assets/extra.css +0 -0
  44. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/automation.md +0 -0
  45. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/bench.md +0 -0
  46. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/changelog.md +0 -0
  47. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/commands.md +0 -0
  48. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/configuration.md +0 -0
  49. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/contributing.md +0 -0
  50. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/editors.md +0 -0
  51. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/git.md +0 -0
  52. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/hooks.md +0 -0
  53. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/how-it-works.md +0 -0
  54. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/index.md +0 -0
  55. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/installation.md +0 -0
  56. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/lsp.md +0 -0
  57. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/mcp.md +0 -0
  58. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/memory.md +0 -0
  59. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/models.md +0 -0
  60. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/sandbox.md +0 -0
  61. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/search.md +0 -0
  62. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/servers.md +0 -0
  63. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/troubleshooting.md +0 -0
  64. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/examples/README.md +0 -0
  65. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/examples/api/fix_tests.py +0 -0
  66. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/examples/commands/review.md +0 -0
  67. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/examples/skills/commit-message/SKILL.md +0 -0
  68. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
  69. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/install.sh +0 -0
  70. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/mkdocs.yml +0 -0
  71. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/overrides/main.html +0 -0
  72. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/pyproject.toml +0 -0
  73. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/scripts/make_logos.py +0 -0
  74. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/scripts/record_demo.py +0 -0
  75. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/scripts/social-preview.html +0 -0
  76. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/__main__.py +0 -0
  77. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/acp.py +0 -0
  78. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/action.py +0 -0
  79. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/api.py +0 -0
  80. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/backends.py +0 -0
  81. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/bench.py +0 -0
  82. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/bench_backend.py +0 -0
  83. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/bench_runtime.py +0 -0
  84. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/catalog.py +0 -0
  85. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/checkpoints.py +0 -0
  86. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/checks.py +0 -0
  87. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/clipboard.py +0 -0
  88. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/cloud.py +0 -0
  89. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/codesearch.py +0 -0
  90. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/config.py +0 -0
  91. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/context.py +0 -0
  92. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/escalate.py +0 -0
  93. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/extensions.py +0 -0
  94. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/frontmatter.py +0 -0
  95. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/gitflow.py +0 -0
  96. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/hardware.py +0 -0
  97. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/hooks.py +0 -0
  98. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/imageopts.py +0 -0
  99. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/jobs.py +0 -0
  100. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/limits.py +0 -0
  101. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/lsp.py +0 -0
  102. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/matching.py +0 -0
  103. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/__init__.py +0 -0
  104. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/auth.py +0 -0
  105. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/catalog.py +0 -0
  106. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/catalog.toml +0 -0
  107. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/client.py +0 -0
  108. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/commands.py +0 -0
  109. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/config.py +0 -0
  110. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/http.py +0 -0
  111. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/manager.py +0 -0
  112. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/protocol.py +0 -0
  113. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/server.py +0 -0
  114. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/stdio.py +0 -0
  115. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/memory.py +0 -0
  116. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/models.toml +0 -0
  117. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/modes.py +0 -0
  118. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/notify.py +0 -0
  119. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/ollama.py +0 -0
  120. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/permissions.py +0 -0
  121. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/planning.py +0 -0
  122. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/preview.py +0 -0
  123. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/render.py +0 -0
  124. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/repeats.py +0 -0
  125. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/repomap.py +0 -0
  126. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/residency.py +0 -0
  127. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/review.py +0 -0
  128. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/sandbox.py +0 -0
  129. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/screenshots.py +0 -0
  130. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/secretscan.py +0 -0
  131. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/servers/__init__.py +0 -0
  132. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/servers/encord.py +0 -0
  133. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/servers/valohai.py +0 -0
  134. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/servers/xai.py +0 -0
  135. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/sessions.py +0 -0
  136. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/steer.py +0 -0
  137. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/subagents.py +0 -0
  138. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/tools.py +0 -0
  139. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/unreachable.py +0 -0
  140. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/update.py +0 -0
  141. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/vision.py +0 -0
  142. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/web.py +0 -0
  143. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/workflows/__init__.py +0 -0
  144. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/workflows/flux2-klein-4b-t2i.json +0 -0
  145. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/workflows/z-image-turbo-t2i.json +0 -0
  146. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/conftest.py +0 -0
  147. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/fake_lsp_server.py +0 -0
  148. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/fake_mcp_server.py +0 -0
  149. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_acp.py +0 -0
  150. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_action.py +0 -0
  151. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_agent.py +0 -0
  152. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_api.py +0 -0
  153. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_backends.py +0 -0
  154. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_bench.py +0 -0
  155. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_catalog.py +0 -0
  156. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_checkpoints.py +0 -0
  157. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_checks.py +0 -0
  158. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_cli.py +0 -0
  159. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_clipboard.py +0 -0
  160. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_cloud.py +0 -0
  161. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_codesearch.py +0 -0
  162. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_config.py +0 -0
  163. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_context.py +0 -0
  164. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_context_picker.py +0 -0
  165. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_escalate.py +0 -0
  166. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_extensions.py +0 -0
  167. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_gitflow.py +0 -0
  168. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_hooks.py +0 -0
  169. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_image_generate.py +0 -0
  170. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_jobs.py +0 -0
  171. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_look.py +0 -0
  172. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_lsp.py +0 -0
  173. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_matching.py +0 -0
  174. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_mcp.py +0 -0
  175. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_memory.py +0 -0
  176. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_model_picker.py +0 -0
  177. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_modes.py +0 -0
  178. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_permissions.py +0 -0
  179. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_plan_mode.py +0 -0
  180. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_repeats.py +0 -0
  181. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_repomap.py +0 -0
  182. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_residency.py +0 -0
  183. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_review.py +0 -0
  184. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_robustness.py +0 -0
  185. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_sandbox.py +0 -0
  186. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_screenshots.py +0 -0
  187. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_secretscan.py +0 -0
  188. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_servers.py +0 -0
  189. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_sessions.py +0 -0
  190. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_steer.py +0 -0
  191. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_stuck.py +0 -0
  192. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_subagents.py +0 -0
  193. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_tools.py +0 -0
  194. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_update.py +0 -0
  195. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_vision.py +0 -0
  196. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_web.py +0 -0
  197. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_xai.py +0 -0
  198. {lcode_cli-0.42.0 → lcode_cli-0.43.0}/uv.lock +0 -0
@@ -6,6 +6,20 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.43.0] - 2026-10-24
10
+
11
+ ### Added
12
+
13
+ - **Image mode edits pictures** (#152). See [Editing](https://nasser1941.github.io/lcode/modes/#editing).
14
+ - **Edit by instruction** (`/edit 3 make the sky purple`, or `@photo.png make the sky purple`)
15
+ and **references** (`@photo.png @#3 put the mug from image 1 on the table in image 2`), with
16
+ FLUX.2 klein 4B. `/use 3` makes every next request follow an image until `/use off`.
17
+ - **Inpainting** (`--mask mask.png`: only the white part changes) and **outpainting** (`/extend 3
18
+ --ar 16:9`, or by sides: only the new border is painted, the original stays as it was).
19
+ - **`/upscale 3`** (Real-ESRGAN, 2× or 4×) and **`/nobg 3`** (BiRefNet, a transparent PNG), installed
20
+ on first use. `/vary` on an edit makes new edits of the same images.
21
+ - ComfyUI's reasons for refusing a workflow now include its explanation, not just the input's name.
22
+
9
23
  ## [0.42.0] - 2026-10-24
10
24
 
11
25
  ### Added
@@ -940,7 +954,8 @@ First public release.
940
954
  - `AGENTS.md` project instructions and `/init` to generate them.
941
955
  - One-line installer for Linux and macOS.
942
956
 
943
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.42.0...HEAD
957
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.43.0...HEAD
958
+ [0.43.0]: https://github.com/nasser1941/lcode/compare/v0.42.0...v0.43.0
944
959
  [0.42.0]: https://github.com/nasser1941/lcode/compare/v0.41.0...v0.42.0
945
960
  [0.41.0]: https://github.com/nasser1941/lcode/compare/v0.40.0...v0.41.0
946
961
  [0.40.0]: https://github.com/nasser1941/lcode/compare/v0.39.0...v0.40.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.42.0
3
+ Version: 0.43.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/
@@ -5,7 +5,7 @@ lcode makes three kinds of things, each in its own mode with its own models:
5
5
  | Mode | What it makes | The model doing the work |
6
6
  |---|---|---|
7
7
  | **code** (default) | Code: it reads, writes, runs and checks your project | A coding language model in Ollama (or another server) |
8
- | **image** | Images from a description and reference images | An image model, run by ComfyUI on your GPU |
8
+ | **image** | Images from a description, edits of your images (references, inpainting, outpainting), upscales and cut-outs | An image model, run by ComfyUI on your GPU |
9
9
  | **video** | Videos from a description, a start image or references | A video model, run by ComfyUI on your GPU |
10
10
 
11
11
  ```text
@@ -81,6 +81,8 @@ alone shows them.
81
81
  - **Sessions:** the gallery and `/options` are saved with the session, so `lcode -c` or `/resume`
82
82
  brings them back. An image session is listed under its first prompt.
83
83
 
84
+ ### While it works
85
+
84
86
  - **Progress:** the status line says what ComfyUI is doing (loading the model, reading the
85
87
  prompt, drawing, decoding), the step, the time left and the time so far. If other jobs are ahead
86
88
  in ComfyUI's queue, it says how many.
@@ -99,6 +101,42 @@ alone shows them.
99
101
  - **No Ollama needed:** `lcode --mode image` starts even when Ollama isn't running or the coding
100
102
  model isn't installed; `/mode code` then says what's missing.
101
103
 
104
+ ### Editing
105
+
106
+ FLUX.2 klein 4B edits pictures as well as making them: it takes images as references and an
107
+ instruction as the prompt. When the chosen model can't edit (Z-Image Turbo), lcode hands edits to
108
+ FLUX.2 klein and says so.
109
+
110
+ ```text
111
+ image ❯ /edit 1 make the mug matte black with a small gold star
112
+ ◆ #2 .lcode/media/20261024-100504-make-the-mug-matte-black-with-a-small-go.png 1024×1024 · FLUX.2 klein 4B · seed 1798225303 · 6.1 s
113
+ image ❯ /extend 1 --ar 16:9
114
+ Extending #1 from 1024×1024 to 1824×1024 before scaling.
115
+ ◆ #4 .lcode/media/…-extended.png 1360×768 · FLUX.2 klein 4B · seed 2994584653 · 6.4 s
116
+ image ❯ /nobg 2
117
+ ◆ #6 .lcode/media/…-nobg.png 1024×1024 · BiRefNet · 0.7 s
118
+ ```
119
+
120
+ | What | How | What happens |
121
+ |---|---|---|
122
+ | **Edit by instruction** | `/edit 3 make the sky purple` (the last image without a number), or `@photo.png make the sky purple` | The image goes in as a reference, the instruction is the prompt. The result keeps the image's shape unless you give one (`--ar`). |
123
+ | **References** | `@photo.png @#3 put the mug from image 1 on the table in image 2`, or `/edit 3 … @style.png` | Several images, in order ("image 1", "image 2" in the instruction): a subject, a style, a place. `@"a file with spaces.png"` works too. |
124
+ | **Keep following an image** | `/use 3` (or `/use photo.png`), then plain requests; `/use off` | Every request is an edit of those images until you stop it: handy for one subject in many scenes. |
125
+ | **Inpaint** | `@photo.png --mask mask.png make the sky purple` | Only where the mask image is white changes (with a soft edge); the rest stays exactly as it was. |
126
+ | **Outpaint** | `/extend 3 --ar 16:9`, or `/extend 3 --left 256 --top 128`, plus words about the new part | The canvas grows, the model paints only the new border, and the original is pasted back as it was. |
127
+ | **Upscale** | `/upscale 3` (2×) or `/upscale 3 4` | Real-ESRGAN, a few seconds: a 1024×1024 image becomes 2048×2048. |
128
+ | **Remove the background** | `/nobg 3` | BiRefNet cuts out the subject: a PNG with a transparent background. |
129
+
130
+ - **Follow-ups work on edits too:** `/vary` on an edit makes new edits of the same images with the
131
+ same instruction, and `/again` repeats the last request, edit or not.
132
+ - **The tools install on first use,** after you agree: Real-ESRGAN (0.07 GB) and BiRefNet (0.44 GB).
133
+ `lcode media` lists them under "Tools for images".
134
+ - **Text in images:** FLUX.2 klein and Z-Image Turbo write short text you put in quotes
135
+ (`a café sign that says "OPEN"`).
136
+ - **Measured** on the RTX 4080 Laptop GPU (12 GB): an edit takes 5 to 7 s, two references 10 s,
137
+ inpainting and outpainting 6 to 12 s, a 2× upscale 6 s, and a background cut-out under a second
138
+ once the tools are loaded.
139
+
102
140
  ## ComfyUI
103
141
 
104
142
  ComfyUI runs the image and video models; lcode drives it through its API.
@@ -198,10 +236,7 @@ themselves:
198
236
 
199
237
  Video mode doesn't generate yet: a request there says what's coming instead of going anywhere.
200
238
 
201
- - **#150 Media model catalog** (image models done; video models come with video generation).
202
- - **#151, #152 Image mode:** generating, variations, sizes and seeds, a gallery, editing,
203
- references, inpainting, upscaling.
204
- - **#153, #154 Video mode:** from text or a start image, first and last frames, several
239
+ - **#153, #154 Video mode:** with video models in the catalog, from text or a start image, first and last frames, several
205
240
  references, camera moves, longer clips, sound.
206
241
  - **#155 Movies:** storyboards with a consistent cast, shots stitched into one film.
207
242
  - **#156 Prompt assistant:** a small local model for prompts and follow-ups.
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.42.0 — local coding agent │
15
+ │ lcode v0.43.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -82,6 +82,7 @@ reasoning is on.
82
82
  | `/verbose` | Toggle showing the reasoning text |
83
83
  | `/mode [code\|image\|video]` | Switch what lcode makes: [code, images or videos](modes.md) |
84
84
  | `/options`, `/again`, `/vary N`, `/gallery`, `/open N` | Image mode: [options for the session, follow-ups on results](modes.md#results) |
85
+ | `/edit N`, `/use N`, `/extend N`, `/upscale N`, `/nobg N` | Image mode: [edit, follow, outpaint, upscale, cut out](modes.md#editing) |
85
86
  | `/permissions [ask\|plan\|auto-edit\|yolo]` | Set the permission mode |
86
87
  | `/cd DIR` | Change the working directory |
87
88
  | `/todos` | Show the model's task list |
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.42.0"
6
+ __version__ = "0.43.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
@@ -380,7 +380,8 @@ class Agent:
380
380
 
381
381
  self.image_options = ImageOptions() # image mode's defaults for this session (/options)
382
382
  self.media_results: list[dict] = [] # the session's images, numbered (/gallery, /vary, /open)
383
- self.last_request: tuple[str, ImageOptions] | None = None # for /again
383
+ self.last_request: dict | None = None # what /again repeats (lcode.media.redo)
384
+ self.image_refs: list[str] = [] # images every next request follows (/use)
384
385
  self.reflected = 0 # messages before this index were already checked for notes to remember
385
386
  self._memory: memory_notes.Memory | None = None
386
387
  self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
@@ -697,7 +698,7 @@ class Agent:
697
698
  "model": self.settings.model,
698
699
  "mode": self.settings.mode,
699
700
  "image_model": self.settings.image_model,
700
- "media": {"results": self.media_results, "options": self.image_options.to_json()},
701
+ "media": {"results": self.media_results, "options": self.image_options.to_json(), "refs": self.image_refs},
701
702
  "name": self.session_name,
702
703
  "title": self.session_title,
703
704
  "messages": self.messages,
@@ -751,6 +752,7 @@ class Agent:
751
752
  media = data.get("media") or {}
752
753
  self.media_results = [r for r in media.get("results") or [] if isinstance(r, dict) and "path" in r]
753
754
  self.image_options = type(self.image_options).from_json(media.get("options") or {})
755
+ self.image_refs = [r for r in media.get("refs") or [] if isinstance(r, str)]
754
756
  self.session_title = data.get("title") or sessions.title_from(self.messages)
755
757
  self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
756
758
  self.tools.todos = list(data.get("todos") or [])
@@ -1107,9 +1107,13 @@ def cmd_media(args) -> None:
1107
1107
  f"ComfyUI at {server.url}" if running else (f"{install.path} (not running)" if install else "no ComfyUI found")
1108
1108
  )
1109
1109
  console.print(f"[dim]{hw.describe()} · {where} · image model: {cfg['image_model']}[/]\n")
1110
- for mode in ("image", "video"):
1110
+ for mode in ("image", "video", "tool"):
1111
1111
  models = cat.for_mode(mode)
1112
- console.print(f"[bold]{mode.capitalize()} models[/]")
1112
+ console.print(
1113
+ "[bold]Tools for images[/] [dim](/upscale, /nobg)[/]"
1114
+ if mode == "tool"
1115
+ else f"[bold]{mode.capitalize()} models[/]"
1116
+ )
1113
1117
  if not models:
1114
1118
  console.print(f" [dim]{media.SOON[mode]}[/]\n")
1115
1119
  continue
@@ -336,8 +336,9 @@ def describe_rejection(r: requests.Response) -> str:
336
336
  lines = [str((data.get("error") or {}).get("message") or "ComfyUI refused the workflow")]
337
337
  for node, info in (data.get("node_errors") or {}).items():
338
338
  for err in info.get("errors", [])[:3]:
339
- detail = err.get("details") or err.get("message") or ""
340
- lines.append(f"{info.get('class_type', 'node')} ({node}): {detail}")
339
+ message, detail = err.get("message") or "", err.get("details") or ""
340
+ why = f"{message}: {detail}" if message and detail and detail not in message else (detail or message)
341
+ lines.append(f"{info.get('class_type', 'node')} ({node}): {why}")
341
342
  return "; ".join(lines)
342
343
 
343
344
 
@@ -0,0 +1,283 @@
1
+ """Image edits as changes to a ComfyUI workflow: reference images, inpainting through a mask, outpainting.
2
+
3
+ A model that edits with reference latents (FLUX.2 klein) takes the images through its own text-to-image workflow:
4
+ each image is scaled, encoded by the VAE and attached to both conditionings before the guider, and the instruction is
5
+ the prompt. On top of that:
6
+
7
+ - **Inpainting** keeps the original outside a mask: the edited picture is pasted into the original only where the
8
+ mask is white (with a soft edge).
9
+ - **Outpainting** puts the image on a bigger canvas, lets the model fill the new border, and pastes the original back,
10
+ so it stays exactly as it was.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import struct
17
+ from pathlib import Path
18
+
19
+ BASE = 8000 # node ids lcode adds start here (the workflows' own are small numbers)
20
+ SIZE_NODE = str(BASE + 9)
21
+ IMAGE_TYPES = (".png", ".jpg", ".jpeg", ".webp", ".bmp")
22
+ EXTEND_HINT = (
23
+ "Replace the blurry border around the sharp picture with sharp, detailed scenery that continues the picture "
24
+ "outward, matching its light, perspective and style. Keep the sharp middle exactly as it is. No frame, no bars."
25
+ )
26
+
27
+
28
+ class EditError(ValueError):
29
+ pass
30
+
31
+
32
+ def node_of(graph: dict, class_type: str) -> str | None:
33
+ return next((nid for nid, node in graph.items() if node.get("class_type") == class_type), None)
34
+
35
+
36
+ def save_node(graph: dict) -> str:
37
+ nid = node_of(graph, "SaveImage")
38
+ if nid is None:
39
+ raise EditError("the workflow saves no image")
40
+ return nid
41
+
42
+
43
+ def load_images(graph: dict, names: list[str]) -> list[list]:
44
+ """LoadImage nodes for uploaded images; their IMAGE outputs."""
45
+ links = []
46
+ for k, name in enumerate(names):
47
+ nid = str(BASE + 100 + k)
48
+ graph[nid] = {"class_type": "LoadImage", "inputs": {"image": name}}
49
+ links.append([nid, 0])
50
+ return links
51
+
52
+
53
+ def add_references(graph: dict, images: list[list], megapixels: float, size_from_first: bool) -> list:
54
+ """Attach the images as reference latents to the guider's positive and negative conditionings.
55
+
56
+ Each is scaled to `megapixels` first. With `size_from_first`, the picture made has the first image's size
57
+ (its width and height go to every node of the workflow that takes them). Returns the first scaled image.
58
+ """
59
+ guider = node_of(graph, "CFGGuider")
60
+ vae = node_of(graph, "VAELoader")
61
+ if guider is None or vae is None:
62
+ raise EditError("this workflow can't take reference images")
63
+ positive, negative = graph[guider]["inputs"]["positive"], graph[guider]["inputs"]["negative"]
64
+ first = None
65
+ for k, image in enumerate(images):
66
+ scale, encode, ref_pos, ref_neg = (str(BASE + 10 * (k + 1) + i) for i in range(4))
67
+ graph[scale] = {
68
+ "class_type": "ImageScaleToTotalPixels",
69
+ "inputs": {"image": image, "upscale_method": "lanczos", "megapixels": megapixels, "resolution_steps": 16},
70
+ }
71
+ graph[encode] = {"class_type": "VAEEncode", "inputs": {"pixels": [scale, 0], "vae": [vae, 0]}}
72
+ graph[ref_pos] = {"class_type": "ReferenceLatent", "inputs": {"conditioning": positive, "latent": [encode, 0]}}
73
+ graph[ref_neg] = {"class_type": "ReferenceLatent", "inputs": {"conditioning": negative, "latent": [encode, 0]}}
74
+ positive, negative = [ref_pos, 0], [ref_neg, 0]
75
+ first = first or [scale, 0]
76
+ graph[guider]["inputs"]["positive"], graph[guider]["inputs"]["negative"] = positive, negative
77
+ if size_from_first and first:
78
+ set_size(graph, first)
79
+ return first
80
+
81
+
82
+ def set_size(graph: dict, image: list) -> None:
83
+ """The picture made takes `image`'s size: its width and height go to every workflow node that takes them."""
84
+ graph[SIZE_NODE] = {"class_type": "GetImageSize", "inputs": {"image": image}}
85
+ for nid, node in graph.items():
86
+ if nid.isdigit() and int(nid) >= BASE:
87
+ continue # lcode's own nodes keep their sizes
88
+ inputs = node.get("inputs", {})
89
+ for key, out in (("width", 0), ("height", 1)):
90
+ if key in inputs and not isinstance(inputs[key], list):
91
+ inputs[key] = [SIZE_NODE, out]
92
+
93
+
94
+ def scale_to(graph: dict, image: list, megapixels: float) -> list:
95
+ nid = str(BASE + 301)
96
+ graph[nid] = {
97
+ "class_type": "ImageScaleToTotalPixels",
98
+ "inputs": {"image": image, "upscale_method": "lanczos", "megapixels": megapixels, "resolution_steps": 16},
99
+ }
100
+ return [nid, 0]
101
+
102
+
103
+ def mask_from_file(graph: dict, mask_name: str) -> list:
104
+ """The user's mask image (white: change), at the picture's size, with a soft edge."""
105
+ if SIZE_NODE not in graph:
106
+ raise EditError("inpainting needs the picture at the original's size")
107
+ load, to_image, scale, blur, to_mask = (str(BASE + 200 + i) for i in range(5))
108
+ graph[load] = {"class_type": "LoadImageMask", "inputs": {"image": mask_name, "channel": "red"}}
109
+ graph[to_image] = {"class_type": "MaskToImage", "inputs": {"mask": [load, 0]}}
110
+ graph[scale] = {
111
+ "class_type": "ImageScale",
112
+ "inputs": {"image": [to_image, 0], "upscale_method": "bilinear", "width": [SIZE_NODE, 0],
113
+ "height": [SIZE_NODE, 1], "crop": "disabled"},
114
+ } # fmt: skip
115
+ graph[blur] = {"class_type": "ImageBlur", "inputs": {"image": [scale, 0], "blur_radius": 8, "sigma": 4.0}}
116
+ graph[to_mask] = {"class_type": "ImageToMask", "inputs": {"image": [blur, 0], "channel": "red"}}
117
+ return [to_mask, 0]
118
+
119
+
120
+ def mask_scaled(graph: dict, mask: list, megapixels: float) -> list:
121
+ """A mask brought to the same size as an image scaled to `megapixels` (outpainting's border)."""
122
+ to_image, scale, to_mask = (str(BASE + 310 + i) for i in range(3))
123
+ graph[to_image] = {"class_type": "MaskToImage", "inputs": {"mask": mask}}
124
+ graph[scale] = {
125
+ "class_type": "ImageScaleToTotalPixels",
126
+ "inputs": {"image": [to_image, 0], "upscale_method": "bilinear", "megapixels": megapixels,
127
+ "resolution_steps": 16},
128
+ } # fmt: skip
129
+ graph[to_mask] = {"class_type": "ImageToMask", "inputs": {"image": [scale, 0], "channel": "red"}}
130
+ return [to_mask, 0]
131
+
132
+
133
+ def sample_inside(graph: dict, image: list, mask: list) -> None:
134
+ """Start from the image itself and let the sampler change only what the mask covers."""
135
+ sampler = node_of(graph, "SamplerCustomAdvanced") or node_of(graph, "KSampler")
136
+ vae = node_of(graph, "VAELoader")
137
+ if sampler is None or vae is None:
138
+ raise EditError("this workflow can't paint inside a mask")
139
+ encode, masked = str(BASE + 400), str(BASE + 401)
140
+ graph[encode] = {"class_type": "VAEEncode", "inputs": {"pixels": image, "vae": [vae, 0]}}
141
+ graph[masked] = {"class_type": "SetLatentNoiseMask", "inputs": {"samples": [encode, 0], "mask": mask}}
142
+ graph[sampler]["inputs"]["latent_image"] = [masked, 0]
143
+
144
+
145
+ def paste(graph: dict, destination: list, source: list, mask: list) -> None:
146
+ """The saved picture becomes `destination` with `source` pasted where the mask is white."""
147
+ save = save_node(graph)
148
+ nid = str(BASE + 500)
149
+ graph[nid] = {
150
+ "class_type": "ImageCompositeMasked",
151
+ "inputs": {"destination": destination, "source": source, "x": 0, "y": 0, "resize_source": False,
152
+ "mask": mask},
153
+ } # fmt: skip
154
+ graph[save]["inputs"]["images"] = [nid, 0]
155
+
156
+
157
+ def inpaint(graph: dict, original: list, mask_name: str) -> None:
158
+ """Change only where the mask image is white: sampled there from the original, then pasted into it."""
159
+ mask = mask_from_file(graph, mask_name)
160
+ sample_inside(graph, original, mask)
161
+ paste(graph, original, graph[save_node(graph)]["inputs"]["images"], mask)
162
+
163
+
164
+ def pad_canvas(graph: dict, image: list, pads: tuple[int, int, int, int]) -> tuple[list, list]:
165
+ """Outpainting's canvas, bigger by `pads` (left, top, right, bottom); (canvas, mask of the new border).
166
+
167
+ The border starts as a blurred, stretched copy of the image rather than flat grey: a fast model keeps a little of
168
+ what it starts from, and grey would show.
169
+ """
170
+ pad, size, stretched, blurred, canvas = (str(BASE + 300 + i) for i in (0, 2, 3, 4, 5))
171
+ left, top, right, bottom = pads
172
+ graph[pad] = {
173
+ "class_type": "ImagePadForOutpaint",
174
+ "inputs": {"image": image, "left": left, "top": top, "right": right, "bottom": bottom, "feathering": 48},
175
+ }
176
+ graph[size] = {"class_type": "GetImageSize", "inputs": {"image": [pad, 0]}}
177
+ graph[stretched] = {
178
+ "class_type": "ImageScale",
179
+ "inputs": {"image": image, "upscale_method": "bilinear", "width": [size, 0], "height": [size, 1],
180
+ "crop": "disabled"},
181
+ } # fmt: skip
182
+ graph[blurred] = {"class_type": "ImageBlur", "inputs": {"image": [stretched, 0], "blur_radius": 31, "sigma": 10.0}}
183
+ graph[canvas] = {
184
+ "class_type": "ImageCompositeMasked",
185
+ "inputs": {"destination": [blurred, 0], "source": image, "x": left, "y": top, "resize_source": False},
186
+ }
187
+ return [canvas, 0], [pad, 1]
188
+
189
+
190
+ def outpaint(graph: dict, canvas_scaled: list, border: list, megapixels: float) -> None:
191
+ """Fill only the new border, sampled around the original, and keep the original exactly as it was."""
192
+ mask = mask_scaled(graph, border, megapixels)
193
+ sample_inside(graph, canvas_scaled, mask)
194
+ invert = str(BASE + 501)
195
+ graph[invert] = {"class_type": "InvertMask", "inputs": {"mask": mask}}
196
+ paste(graph, graph[save_node(graph)]["inputs"]["images"], canvas_scaled, [invert, 0])
197
+
198
+
199
+ # ----------------------------------------------------------------------------- sizes and arguments
200
+
201
+
202
+ def image_size(path: Path) -> tuple[int, int]:
203
+ """Width and height from a PNG, JPEG or WebP file's header."""
204
+ data = path.read_bytes()[:65536]
205
+ if data.startswith(b"\x89PNG\r\n\x1a\n"):
206
+ return struct.unpack(">II", data[16:24])
207
+ if data[:2] == b"\xff\xd8":
208
+ i = 2
209
+ while i + 9 < len(data):
210
+ if data[i] != 0xFF:
211
+ i += 1
212
+ continue
213
+ marker = data[i + 1]
214
+ length = struct.unpack(">H", data[i + 2 : i + 4])[0]
215
+ if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
216
+ h, w = struct.unpack(">HH", data[i + 5 : i + 9])
217
+ return w, h
218
+ i += 2 + length
219
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
220
+ kind = data[12:16]
221
+ if kind == b"VP8X":
222
+ return int.from_bytes(data[24:27], "little") + 1, int.from_bytes(data[27:30], "little") + 1
223
+ if kind == b"VP8 ":
224
+ w, h = struct.unpack("<HH", data[26:30])
225
+ return w & 0x3FFF, h & 0x3FFF
226
+ if kind == b"VP8L":
227
+ bits = int.from_bytes(data[21:25], "little")
228
+ return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
229
+ raise EditError(f"can't read the size of {path.name}: use a PNG, JPEG or WebP image")
230
+
231
+
232
+ def pads_for_ratio(width: int, height: int, ratio: str) -> tuple[int, int, int, int]:
233
+ """Borders that bring the image to an aspect ratio (a:b), split evenly, in steps of 8 pixels."""
234
+ m = re.fullmatch(r"(\d{1,2}):(\d{1,2})", ratio)
235
+ if not m or not int(m.group(1)) or not int(m.group(2)):
236
+ raise EditError("the shape to extend to is a ratio, e.g. --ar 16:9")
237
+ a, b = int(m.group(1)), int(m.group(2))
238
+ if abs(width / height - a / b) < 0.01:
239
+ raise EditError(f"it's {a}:{b} already: choose another shape, or --left/--right/--top/--bottom pixels")
240
+ if width / height < a / b: # wider
241
+ extra = round(height * a / b) - width
242
+ left = (extra // 2) // 8 * 8
243
+ return left, 0, (extra - left + 7) // 8 * 8, 0
244
+ extra = round(width * b / a) - height
245
+ top = (extra // 2) // 8 * 8
246
+ return 0, top, 0, (extra - top + 7) // 8 * 8
247
+
248
+
249
+ EXTEND_FLAG = re.compile(r"--(left|top|right|bottom)\s+(\d+)")
250
+
251
+
252
+ def parse_extend(text: str, width: int, height: int) -> tuple[tuple[int, int, int, int], str]:
253
+ """`/extend` arguments: --ar 16:9, or --left/--top/--right/--bottom pixels; the rest describes the new part."""
254
+ sides = {k: int(v) for k, v in EXTEND_FLAG.findall(text)}
255
+ text = EXTEND_FLAG.sub(" ", text)
256
+ ratio = re.search(r"--(?:ar|size)\s+(\S+)", text)
257
+ text = re.sub(r"--(?:ar|size)\s+\S+", " ", text)
258
+ if ratio and sides:
259
+ raise EditError("extend by a shape (--ar 16:9) or by sides (--left 256), not both")
260
+ if ratio:
261
+ from lcode.imageopts import SIZES
262
+
263
+ pads = pads_for_ratio(width, height, SIZES.get(ratio.group(1), ratio.group(1)))
264
+ elif sides:
265
+ pads = tuple((sides.get(k, 0) + 7) // 8 * 8 for k in ("left", "top", "right", "bottom"))
266
+ if any(p > 2048 for p in pads):
267
+ raise EditError("a side grows by at most 2048 pixels")
268
+ else:
269
+ pads = pads_for_ratio(width, height, "16:9" if width <= height * 16 / 9 - 1 else "1:1")
270
+ return pads, " ".join(text.split())
271
+
272
+
273
+ REFERENCE = re.compile(r'(?:^|(?<=\s))@(#\d+|"[^"]+"|\S+)')
274
+
275
+
276
+ def split_references(text: str) -> tuple[str, list[str]]:
277
+ """The prompt without its @references, and the references (#3, or file paths) in order."""
278
+ refs = [m.group(1).strip('"') for m in REFERENCE.finditer(text)]
279
+ return " ".join(REFERENCE.sub(" ", text).split()), refs
280
+
281
+
282
+ def is_image(path: Path) -> bool:
283
+ return path.suffix.lower() in IMAGE_TYPES