lcode-cli 0.23.0__tar.gz → 0.25.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.
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/CHANGELOG.md +35 -1
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/PKG-INFO +1 -1
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/bench.md +4 -2
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/how-it-works.md +13 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/quickstart.md +1 -1
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/usage.md +13 -2
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/pyproject.toml +1 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/agent.py +67 -14
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/bench.py +9 -2
- lcode_cli-0.25.0/src/lcode/bench_runtime.py +156 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/cli.py +4 -0
- lcode_cli-0.25.0/src/lcode/clipboard.py +139 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/repl.py +22 -1
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_bench.py +25 -0
- lcode_cli-0.25.0/tests/test_clipboard.py +118 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_vision.py +40 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/.editorconfig +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/.github/dependabot.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/.gitignore +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/AGENTS.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/LICENSE +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/README.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/SECURITY.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/action.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/agents.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/assets/extra.css +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/assets/logo.svg +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/assets/social-preview.png +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/automation.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/changelog.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/commands.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/configuration.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/contributing.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/editors.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/git.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/hooks.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/index.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/installation.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/lsp.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/mcp.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/memory.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/models.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/sandbox.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/search.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/servers.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/examples/README.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/examples/api/fix_tests.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/examples/commands/review.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/examples/skills/commit-message/SKILL.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/install.sh +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/mkdocs.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/overrides/main.html +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/scripts/record_demo.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/scripts/social-preview.html +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/acp.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/action.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/api.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/backends.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/bench_backend.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/codesearch.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/config.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/context.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/extensions.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/frontmatter.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/gitflow.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/hooks.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/jobs.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/limits.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/lsp.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/mcp/catalog.toml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/mcp/server.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/memory.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/models.toml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/notify.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/permissions.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/planning.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/render.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/repeats.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/repomap.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/servers/__init__.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/servers/encord.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/servers/valohai.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/servers/xai.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/subagents.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/tools.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/update.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/vision.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/src/lcode/web.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/conftest.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/fake_lsp_server.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_acp.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_action.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_agent.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_api.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_backends.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_catalog.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_cli.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_codesearch.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_config.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_context.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_extensions.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_gitflow.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_hooks.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_jobs.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_lsp.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_mcp.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_memory.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_permissions.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_plan_mode.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_repeats.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_repomap.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_servers.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_sessions.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_subagents.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_tools.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_update.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_web.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.0}/tests/test_xai.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.25.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.25.0] - 2026-10-10
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- The model is told what counts as checking its work: run what it changed the way it will be used,
|
|
14
|
+
reproduce each reported problem before fixing it, check what it can't see (GUIs, web pages,
|
|
15
|
+
layouts) without a screen, for example with Qt's `offscreen` platform, a headless browser or a
|
|
16
|
+
rendered image, and never call something tested or done without having run it. See
|
|
17
|
+
[Checking its own work](https://nasser1941.github.io/lcode/how-it-works/#checking-its-own-work).
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `lcode bench --tasks runtime-bug`: a bug that shows only when the code runs, a camera wall whose
|
|
22
|
+
window grows with every video frame. Reading the code suggests band-aids that fail its hidden
|
|
23
|
+
test. It isn't in the default set.
|
|
24
|
+
|
|
25
|
+
## [0.24.0] - 2026-10-10
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- Ctrl+V in lcode's prompt pastes a screenshot: lcode reads the clipboard (with `wl-paste` on
|
|
30
|
+
Wayland, `xclip` on X11, `pngpaste` or `osascript` on macOS), saves the image and attaches it as
|
|
31
|
+
`@path`. Other clipboard content is pasted as text. `lcode doctor` shows whether it works here.
|
|
32
|
+
- Image paths in a message are attached without `@`: absolute and `~` paths, quoted paths with
|
|
33
|
+
spaces, and `file://` paths dragged into the terminal. `@"path with spaces"` works for any file.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- An image path that doesn't exist is no longer ignored: lcode says so, and tells the model it can't
|
|
38
|
+
see that image, so it asks instead of guessing what the picture shows. See
|
|
39
|
+
[Images](https://nasser1941.github.io/lcode/usage/#images).
|
|
40
|
+
|
|
9
41
|
## [0.23.0] - 2026-10-10
|
|
10
42
|
|
|
11
43
|
### Added
|
|
@@ -508,7 +540,9 @@ First public release.
|
|
|
508
540
|
- `AGENTS.md` project instructions and `/init` to generate them.
|
|
509
541
|
- One-line installer for Linux and macOS.
|
|
510
542
|
|
|
511
|
-
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.
|
|
543
|
+
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.25.0...HEAD
|
|
544
|
+
[0.25.0]: https://github.com/nasser1941/lcode/compare/v0.24.0...v0.25.0
|
|
545
|
+
[0.24.0]: https://github.com/nasser1941/lcode/compare/v0.23.0...v0.24.0
|
|
512
546
|
[0.23.0]: https://github.com/nasser1941/lcode/compare/v0.22.0...v0.23.0
|
|
513
547
|
[0.22.0]: https://github.com/nasser1941/lcode/compare/v0.21.0...v0.22.0
|
|
514
548
|
[0.21.0]: https://github.com/nasser1941/lcode/compare/v0.20.0...v0.21.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.25.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/
|
|
@@ -38,8 +38,10 @@ A model usually takes 4 to 12 minutes; each task stops after 5 minutes (`--timeo
|
|
|
38
38
|
### More tasks and features
|
|
39
39
|
|
|
40
40
|
`--tasks find-concept` adds a task in a larger repository: 40 files of a small backend where the
|
|
41
|
-
model has to find a function by what it does.
|
|
42
|
-
|
|
41
|
+
model has to find a function by what it does. `--tasks runtime-bug` adds a bug that shows only
|
|
42
|
+
when the code runs: a camera wall's window grows taller with every video frame and can't be made
|
|
43
|
+
smaller again. Reading the code suggests band-aids that fail the hidden test; running it shows the
|
|
44
|
+
cause. Neither is in the default set, so results stay comparable with earlier versions. `--repo-map` gives the model the [repository map](search.md) and
|
|
43
45
|
`--code-search` indexes each task's folder for [semantic search](search.md#semantic-code-search);
|
|
44
46
|
both are off in `lcode bench` by default, for the same reason.
|
|
45
47
|
|
|
@@ -29,6 +29,19 @@ All tools return text, and failures come back to the model as `Error: …` messa
|
|
|
29
29
|
crashing the session, so the model can correct itself (for example re-read a file when an edit
|
|
30
30
|
didn't match).
|
|
31
31
|
|
|
32
|
+
### Checking its own work
|
|
33
|
+
|
|
34
|
+
Small models like to call work done after a syntax check. The system prompt tells the model what
|
|
35
|
+
counts as checked:
|
|
36
|
+
|
|
37
|
+
- run what it changed the way it will be used;
|
|
38
|
+
- reproduce a bug before fixing it, then show it's gone;
|
|
39
|
+
- check what it can't see (a GUI, a web page, a layout) without a screen: Qt's `offscreen`
|
|
40
|
+
platform, a headless browser, or a script that prints sizes or renders an image it then looks
|
|
41
|
+
at;
|
|
42
|
+
- never mark a task done or call something tested without having run it, and tell you what to
|
|
43
|
+
look at when it couldn't check.
|
|
44
|
+
|
|
32
45
|
### When the model repeats itself
|
|
33
46
|
|
|
34
47
|
Small models sometimes fall into a loop: they make a call, get a result, and make the very same call
|
|
@@ -154,8 +154,19 @@ lcode can look at screenshots, mockups, diagrams and photos:
|
|
|
154
154
|
❯ make the settings page match @design/settings.png
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
-
-
|
|
158
|
-
|
|
157
|
+
- **Paste a screenshot with ++ctrl+v++.** A terminal can only paste text, so lcode reads the
|
|
158
|
+
clipboard itself, saves the image in its state folder (`~/.local/state/lcode/pasted/`, the last
|
|
159
|
+
50) and puts `@path` in the prompt. Anything else on the clipboard is pasted as text. It needs
|
|
160
|
+
`wl-clipboard` on Wayland or `xclip` on X11 (`sudo apt install wl-clipboard`); on macOS it works
|
|
161
|
+
as is, and faster with `brew install pngpaste`. `lcode doctor` shows whether it works.
|
|
162
|
+
- Attach an image with `@path`, like a file, or just write its path: `/tmp/shot.png`,
|
|
163
|
+
`~/Pictures/bug.png`, `'./shots/My Shot.png'` and `file://` paths dragged into the terminal are
|
|
164
|
+
attached too. lcode describes the image in detail, with all visible text transcribed and with your
|
|
165
|
+
question in mind, and the model works from that description.
|
|
166
|
+
- **A path to an image that doesn't exist** is reported right away
|
|
167
|
+
(`/tmp/pasted-image-.png doesn't exist, so the model can't see it`), and the model is told it
|
|
168
|
+
can't see the image, so it asks instead of guessing what it shows. A bare name such as `logo.png`
|
|
169
|
+
may be a file you want created, so it's left alone.
|
|
159
170
|
- The model can open images itself with the `view_image` tool, for example a screenshot a test wrote.
|
|
160
171
|
- Screenshots that [MCP](mcp.md) tools return, such as the Playwright browser's, are described too.
|
|
161
172
|
|
|
@@ -82,6 +82,7 @@ ignore = [
|
|
|
82
82
|
"src/lcode/context.py" = ["E501"]
|
|
83
83
|
"src/lcode/lsp.py" = ["E501"]
|
|
84
84
|
"src/lcode/repomap.py" = ["E501"]
|
|
85
|
+
"src/lcode/bench_runtime.py" = ["E501"] # the task's files and hidden test
|
|
85
86
|
"src/lcode/codesearch.py" = ["E501"]
|
|
86
87
|
"src/lcode/bench_backend.py" = ["E501"]
|
|
87
88
|
"src/lcode/backends.py" = ["E501"]
|
|
@@ -11,6 +11,7 @@ import shutil
|
|
|
11
11
|
import subprocess
|
|
12
12
|
import threading
|
|
13
13
|
import time
|
|
14
|
+
import urllib.parse
|
|
14
15
|
import uuid
|
|
15
16
|
from collections.abc import Callable
|
|
16
17
|
from dataclasses import dataclass
|
|
@@ -63,6 +64,15 @@ MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren
|
|
|
63
64
|
MALFORMED_CALL = ("error parsing tool call", "XML syntax error")
|
|
64
65
|
AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
|
|
65
66
|
PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
|
|
67
|
+
# @path, @"path with spaces" or @'path'
|
|
68
|
+
MENTION = re.compile(r"""(?<!\S)@(?:"([^"\n]+)"|'([^'\n]+)'|([\w./~\-]+))""")
|
|
69
|
+
# An image path written in a message: in quotes, or without spaces (a space escaped as "\ " is fine), or as a
|
|
70
|
+
# file:// address, the way terminals paste a dragged file.
|
|
71
|
+
IMAGE_PATH = re.compile(
|
|
72
|
+
r"""(['"])((?:file://)?[^'"\n]+?\.(?:png|jpe?g|webp|gif))\1"""
|
|
73
|
+
r"""|(?<![^\s(])((?:[^\s\\'"(]|\\ )+?\.(?:png|jpe?g|webp|gif))(?=[\s,;:!?)\]]|\.(?:\s|$)|$)""",
|
|
74
|
+
re.IGNORECASE,
|
|
75
|
+
)
|
|
66
76
|
PROJECT_FILES = ("AGENTS.md", "LCODE.md", "CLAUDE.md")
|
|
67
77
|
|
|
68
78
|
SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent running in the user's terminal on their own machine, powered by the local model {model}. You help the user understand codebases, answer questions about code, write scripts (Python by default), fix bugs, refactor, and run commands.
|
|
@@ -71,14 +81,18 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
|
|
|
71
81
|
- Use your tools to gather facts. Never guess file contents, APIs, or command output — read, search, or run them.
|
|
72
82
|
- To understand a repository: look at the layout, README/docs, entry points and config, then grep and read the relevant files. Cite code as `path:line`.
|
|
73
83
|
- Read a file before editing it. Use edit_file for targeted changes and write_file for new files or full rewrites. old_string must be copied exactly from the file (without the line-number prefixes).
|
|
74
|
-
- After writing or changing code, verify it: run the script, the tests, or at least a syntax check (e.g. `{python} -m py_compile file.py`). Fix what fails.
|
|
75
84
|
- Match the existing code style, naming and structure. Don't add unrequested features.
|
|
76
85
|
- For multi-step tasks, plan with todo_write and keep it updated.
|
|
77
86
|
- Work autonomously until the task is done. Only stop to ask the user when you are genuinely blocked or the decision is theirs.
|
|
78
87
|
- Don't run destructive or irreversible commands (rm -rf, git reset --hard, git push --force, dropping data) unless the user explicitly asked.
|
|
79
|
-
- Never claim something works without having run it. If something fails, say so and show the relevant error.
|
|
80
88
|
- If the user denies a tool call, don't retry the same thing; adjust or ask.
|
|
81
89
|
|
|
90
|
+
# Verifying your work
|
|
91
|
+
- A syntax check or an import only shows that code loads. Run what you changed the way it will be used (the script, the tests, the app) and fix what fails.
|
|
92
|
+
- To fix a bug, first reproduce each problem the user reports with a test, a command or a short script, then run the same checks to show they're gone.
|
|
93
|
+
- For what you can't see, like a GUI, a web page or a layout, check it without a screen where you can: `QT_QPA_PLATFORM=offscreen` for Qt, a headless browser, or a script that prints sizes and positions or renders to an image{look}. Measure instead of assuming.
|
|
94
|
+
- Never claim something works, mark a todo done, or call it tested unless you ran it. If you couldn't check something, say so and tell the user exactly what to look for. If something fails, say so and show the relevant error.
|
|
95
|
+
|
|
82
96
|
# Communication
|
|
83
97
|
- Be concise and direct; no filler. Use GitHub-flavored markdown.
|
|
84
98
|
- When you finish, give a short summary of what you found or changed and anything the user needs to do.
|
|
@@ -307,6 +321,7 @@ class Agent:
|
|
|
307
321
|
skills=extensions.skills_prompt(self.extensions().skills, self.settings.context),
|
|
308
322
|
code=self.code_prompt(),
|
|
309
323
|
map=self.map_prompt(),
|
|
324
|
+
look=" you look at with view_image" if self.vision_model() else "",
|
|
310
325
|
)
|
|
311
326
|
|
|
312
327
|
def repo_map(self) -> repomap.RepoMap | None:
|
|
@@ -965,30 +980,68 @@ class Agent:
|
|
|
965
980
|
)
|
|
966
981
|
|
|
967
982
|
def expand_mentions(self, text: str) -> str:
|
|
968
|
-
"""
|
|
969
|
-
|
|
970
|
-
|
|
983
|
+
"""Attach what the user's message points to: files and folders written as @path, image paths (with or
|
|
984
|
+
without @), and @name of an agent asks for that agent. An image path that doesn't exist is reported to
|
|
985
|
+
the user and the model, so the model doesn't guess what the image shows."""
|
|
986
|
+
attached: list[str] = []
|
|
987
|
+
seen: set[Path] = set()
|
|
988
|
+
question = MENTION.sub("", text)
|
|
989
|
+
|
|
990
|
+
def attach_image(p: Path) -> None:
|
|
991
|
+
try:
|
|
992
|
+
description = self.look(p, question)
|
|
993
|
+
who = f' described_by="{self.vision_model()}"'
|
|
994
|
+
except ToolError as e:
|
|
995
|
+
description, who = f"(lcode couldn't look at this image: {e})", ""
|
|
996
|
+
self.console.print(Text(f" ⎿ {e}", style="yellow"))
|
|
997
|
+
else:
|
|
998
|
+
self.console.print(Text(f" ⎿ looked at {self.tools.rel(p)}", style="dim"))
|
|
999
|
+
attached.append(f'<image path="{self.tools.rel(p)}"{who}>\n{description}\n</image>')
|
|
1000
|
+
|
|
1001
|
+
def missing_image(shown: str) -> None:
|
|
1002
|
+
self.console.print(
|
|
1003
|
+
Text(f" ⎿ {shown} doesn't exist, so the model can't see it. Paste images with Ctrl+V.", style="yellow")
|
|
1004
|
+
)
|
|
1005
|
+
attached.append(
|
|
1006
|
+
f"[lcode: the user's message mentions the image {shown}, but there's no such file, so it wasn't "
|
|
1007
|
+
"attached and you can't see it. Don't guess what it shows: ask the user to paste it again (Ctrl+V "
|
|
1008
|
+
"in lcode's prompt) or to describe it.]"
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
for match in MENTION.finditer(text):
|
|
1012
|
+
ref = next(g for g in match.groups() if g)
|
|
971
1013
|
p = self.tools.resolve(ref)
|
|
1014
|
+
seen.add(p)
|
|
972
1015
|
name = ref.removeprefix("agent-")
|
|
973
1016
|
if not p.exists() and self.settings.subagents and name in self.agent_types():
|
|
974
1017
|
attached.append(f'[The user wants the {name} agent for this: call the agent tool with type "{name}".]')
|
|
975
1018
|
continue
|
|
976
1019
|
if p.is_file() and vision.is_image(p):
|
|
977
|
-
|
|
978
|
-
description = self.look(p, re.sub(r"(?<!\S)@[\w./~\-]+", "", text))
|
|
979
|
-
who = f' described_by="{self.vision_model()}"'
|
|
980
|
-
except ToolError as e:
|
|
981
|
-
description, who = f"(lcode couldn't look at this image: {e})", ""
|
|
982
|
-
self.console.print(Text(f" ⎿ {e}", style="yellow"))
|
|
983
|
-
else:
|
|
984
|
-
self.console.print(Text(f" ⎿ looked at {self.tools.rel(p)}", style="dim"))
|
|
985
|
-
attached.append(f'<image path="{self.tools.rel(p)}"{who}>\n{description}\n</image>')
|
|
1020
|
+
attach_image(p)
|
|
986
1021
|
elif p.is_file() and not is_binary(p) and p.stat().st_size < 200_000:
|
|
987
1022
|
attached.append(f'<file path="{self.tools.rel(p)}">\n{p.read_text(errors="replace")}\n</file>')
|
|
988
1023
|
self.tools.read_mtimes[str(p)] = p.stat().st_mtime
|
|
989
1024
|
self.console.print(Text(f" ⎿ attached {self.tools.rel(p)}", style="dim"))
|
|
990
1025
|
elif p.is_dir():
|
|
991
1026
|
attached.append(f'<directory path="{self.tools.rel(p)}">\n{tree(p, 2)}\n</directory>')
|
|
1027
|
+
elif not p.exists() and vision.is_image(p):
|
|
1028
|
+
missing_image(ref)
|
|
1029
|
+
for match in IMAGE_PATH.finditer(text):
|
|
1030
|
+
quoted, raw = bool(match.group(2)), match.group(2) or match.group(3)
|
|
1031
|
+
if raw.startswith("@") or "://" in raw.removeprefix("file://"):
|
|
1032
|
+
continue # attached above, or a web address
|
|
1033
|
+
if raw.startswith("file://"):
|
|
1034
|
+
shown = urllib.parse.unquote(raw.removeprefix("file://"))
|
|
1035
|
+
else:
|
|
1036
|
+
shown = raw.replace("\\ ", " ")
|
|
1037
|
+
p = self.tools.resolve(shown)
|
|
1038
|
+
if p in seen:
|
|
1039
|
+
continue
|
|
1040
|
+
seen.add(p)
|
|
1041
|
+
if p.is_file():
|
|
1042
|
+
attach_image(p)
|
|
1043
|
+
elif quoted or raw.startswith(("/", "~", "./", "../", "file://")):
|
|
1044
|
+
missing_image(shown) # a name without a folder may be a file to create: no warning
|
|
992
1045
|
return text + ("\n\n" + "\n\n".join(attached) if attached else "")
|
|
993
1046
|
|
|
994
1047
|
# -- context management
|
|
@@ -746,7 +746,7 @@ TASKS = [
|
|
|
746
746
|
|
|
747
747
|
def extra_tasks() -> list[Task]:
|
|
748
748
|
"""Tasks that only run when asked for with --tasks (they're slower, or test optional features)."""
|
|
749
|
-
from lcode import bench_backend
|
|
749
|
+
from lcode import bench_backend, bench_runtime
|
|
750
750
|
|
|
751
751
|
return [
|
|
752
752
|
Task(
|
|
@@ -755,7 +755,14 @@ def extra_tasks() -> list[Task]:
|
|
|
755
755
|
bench_backend.PROMPT,
|
|
756
756
|
bench_backend.FILES,
|
|
757
757
|
bench_backend.check,
|
|
758
|
-
)
|
|
758
|
+
),
|
|
759
|
+
Task(
|
|
760
|
+
"runtime-bug",
|
|
761
|
+
"Fix a bug that shows only when it runs",
|
|
762
|
+
bench_runtime.PROMPT,
|
|
763
|
+
bench_runtime.FILES,
|
|
764
|
+
bench_runtime.check,
|
|
765
|
+
),
|
|
759
766
|
]
|
|
760
767
|
|
|
761
768
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""A bug that only shows when the code runs, for `lcode bench --tasks runtime-bug`.
|
|
2
|
+
|
|
3
|
+
A camera wall lays out video panels in a window. While video plays, the window grows taller frame after
|
|
4
|
+
frame, past the screen, and can't be made smaller again: each panel's minimum size is taken from the
|
|
5
|
+
frame it shows, and the frame is scaled to the whole cell, status bar included. Reading the code, the
|
|
6
|
+
obvious fixes are band-aids (ignore the minimum sizes, or only fix the scaling) that fail part of the
|
|
7
|
+
hidden test. Running it, the window sizes show the ratchet. It's the kind of bug a GUI has, without
|
|
8
|
+
needing one, and rewards reproducing a problem before fixing it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
WALL = '''\
|
|
17
|
+
"""A camera wall: a window with a grid of panels, each showing its camera's latest video frame."""
|
|
18
|
+
|
|
19
|
+
SPACING = 8 # pixels between and around the panels
|
|
20
|
+
STATUS_BAR = 20 # under its video, each panel shows the camera's name and status
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Panel:
|
|
24
|
+
def __init__(self, name):
|
|
25
|
+
self.name = name
|
|
26
|
+
self.min_width = self.min_height = 1 # the panel can't get smaller than this
|
|
27
|
+
self.video = (0, 0) # the size of the frame on screen
|
|
28
|
+
|
|
29
|
+
def show(self, frame_width, frame_height, cell_width, cell_height):
|
|
30
|
+
"""Show a frame scaled to fit the panel's cell, keeping its aspect ratio."""
|
|
31
|
+
scale = min(cell_width / frame_width, cell_height / frame_height)
|
|
32
|
+
self.video = (int(frame_width * scale), int(frame_height * scale))
|
|
33
|
+
# The video and the status bar under it must stay visible.
|
|
34
|
+
self.min_width, self.min_height = self.video[0], self.video[1] + STATUS_BAR
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Wall:
|
|
38
|
+
def __init__(self, names, rows, cols):
|
|
39
|
+
self.rows, self.cols = rows, cols
|
|
40
|
+
self.panels = [Panel(name) for name in names]
|
|
41
|
+
self.width = self.height = 0
|
|
42
|
+
|
|
43
|
+
def resize(self, width, height):
|
|
44
|
+
"""Ask for a window size. The window can't be smaller than its panels need."""
|
|
45
|
+
need_width = max(p.min_width for p in self.panels) * self.cols + SPACING * (self.cols + 1)
|
|
46
|
+
need_height = max(p.min_height for p in self.panels) * self.rows + SPACING * (self.rows + 1)
|
|
47
|
+
self.width, self.height = max(width, need_width), max(height, need_height)
|
|
48
|
+
|
|
49
|
+
def cell(self):
|
|
50
|
+
"""The size of one panel's cell."""
|
|
51
|
+
return (
|
|
52
|
+
(self.width - SPACING * (self.cols + 1)) // self.cols,
|
|
53
|
+
(self.height - SPACING * (self.rows + 1)) // self.rows,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def new_frame(self, frame_width, frame_height):
|
|
57
|
+
"""Every camera delivered a frame: show it in each panel, then let the layout settle."""
|
|
58
|
+
cell_width, cell_height = self.cell()
|
|
59
|
+
for panel in self.panels:
|
|
60
|
+
panel.show(frame_width, frame_height, cell_width, cell_height)
|
|
61
|
+
self.resize(self.width, self.height)
|
|
62
|
+
'''
|
|
63
|
+
|
|
64
|
+
SIMULATE = '''\
|
|
65
|
+
"""Play the camera wall without a screen: python simulate.py [WIDTH HEIGHT [ROWSxCOLS]]"""
|
|
66
|
+
|
|
67
|
+
import sys
|
|
68
|
+
|
|
69
|
+
from wall import Wall
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main():
|
|
73
|
+
width, height = (int(sys.argv[1]), int(sys.argv[2])) if len(sys.argv) > 2 else (2560, 1080)
|
|
74
|
+
rows, cols = map(int, sys.argv[3].split("x")) if len(sys.argv) > 3 else (3, 3)
|
|
75
|
+
wall = Wall([f"camera {n + 1}" for n in range(rows * cols)], rows, cols)
|
|
76
|
+
wall.resize(width * 9 // 10, height * 9 // 10)
|
|
77
|
+
print(f"{rows}x{cols} wall on a {width}x{height} screen, window {wall.width}x{wall.height}")
|
|
78
|
+
for n in range(1, 6):
|
|
79
|
+
wall.new_frame(1920, 1080)
|
|
80
|
+
print(f"after frame {n}: window {wall.width}x{wall.height}, video {wall.panels[0].video}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
main()
|
|
85
|
+
'''
|
|
86
|
+
|
|
87
|
+
FILES = {"wall.py": WALL, "simulate.py": SIMULATE}
|
|
88
|
+
|
|
89
|
+
PROMPT = (
|
|
90
|
+
"Users say the camera wall in wall.py keeps growing taller while video plays, until it's bigger than "
|
|
91
|
+
"their screen, and after that it can't be made smaller again. Fix it."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
HIDDEN_TEST = """\
|
|
95
|
+
import unittest
|
|
96
|
+
|
|
97
|
+
from wall import SPACING, STATUS_BAR, Wall
|
|
98
|
+
|
|
99
|
+
SCREENS = [(2560, 1080, 3, 3), (1920, 1080, 2, 3), (1366, 768, 3, 3), (3440, 1440, 2, 4), (1280, 1024, 2, 2)]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def play(width, height, rows, cols, frames=30, frame=(1920, 1080)):
|
|
103
|
+
wall = Wall([f"camera {n}" for n in range(rows * cols)], rows, cols)
|
|
104
|
+
wall.resize(width, height)
|
|
105
|
+
for _ in range(frames):
|
|
106
|
+
wall.new_frame(*frame)
|
|
107
|
+
return wall
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class HiddenTest(unittest.TestCase):
|
|
111
|
+
def test_keeps_its_size(self):
|
|
112
|
+
for width, height, rows, cols in SCREENS:
|
|
113
|
+
with self.subTest(screen=(width, height), grid=(rows, cols)):
|
|
114
|
+
wall = play(width * 9 // 10, height * 9 // 10, rows, cols)
|
|
115
|
+
self.assertEqual((wall.width, wall.height), (width * 9 // 10, height * 9 // 10))
|
|
116
|
+
|
|
117
|
+
def test_can_be_made_smaller(self):
|
|
118
|
+
wall = play(2304, 972, 3, 3)
|
|
119
|
+
wall.resize(1280, 720)
|
|
120
|
+
wall.new_frame(1920, 1080)
|
|
121
|
+
self.assertEqual((wall.width, wall.height), (1280, 720))
|
|
122
|
+
|
|
123
|
+
def test_video_fits_and_fills_its_cell(self):
|
|
124
|
+
for width, height, rows, cols in SCREENS:
|
|
125
|
+
for frame in [(1920, 1080), (1280, 960), (720, 1280)]:
|
|
126
|
+
with self.subTest(screen=(width, height), grid=(rows, cols), frame=frame):
|
|
127
|
+
wall = play(width * 9 // 10, height * 9 // 10, rows, cols, frames=3, frame=frame)
|
|
128
|
+
cell_width = (wall.width - SPACING * (cols + 1)) // cols
|
|
129
|
+
cell_height = (wall.height - SPACING * (rows + 1)) // rows
|
|
130
|
+
for panel in wall.panels:
|
|
131
|
+
video_width, video_height = panel.video
|
|
132
|
+
self.assertLessEqual(video_width, cell_width)
|
|
133
|
+
self.assertLessEqual(video_height + STATUS_BAR, cell_height)
|
|
134
|
+
fills = video_width >= cell_width - 2 or video_height + STATUS_BAR >= cell_height - 2
|
|
135
|
+
self.assertTrue(fills, "the video doesn't use the room it has")
|
|
136
|
+
self.assertAlmostEqual(video_width / video_height, frame[0] / frame[1], delta=0.02)
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
PROBLEMS = {
|
|
140
|
+
"test_keeps_its_size": "the window still grows",
|
|
141
|
+
"test_can_be_made_smaller": "the window can't be made smaller",
|
|
142
|
+
"test_video_fits_and_fills_its_cell": "the video doesn't fit its cell above the status bar (or doesn't fill it)",
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def check(folder: Path, answer: str):
|
|
147
|
+
from lcode.bench import Check, last_line, run_python
|
|
148
|
+
|
|
149
|
+
if not (folder / "wall.py").is_file():
|
|
150
|
+
return Check(False, "wall.py is missing")
|
|
151
|
+
(folder / "test_hidden_wall.py").write_text(HIDDEN_TEST)
|
|
152
|
+
r = run_python(folder, "-m", "unittest", "-q", "test_hidden_wall")
|
|
153
|
+
if r.returncode == 0:
|
|
154
|
+
return Check(True, "keeps its size, can be made smaller, and the video fits")
|
|
155
|
+
failed = [text for name, text in PROBLEMS.items() if re.search(rf"\b{name}\b", r.stderr)]
|
|
156
|
+
return Check(False, "; ".join(failed) or last_line(r.stderr) or last_line(r.stdout))
|
|
@@ -346,6 +346,10 @@ def cmd_doctor(args) -> None:
|
|
|
346
346
|
line("Vision", "off; set vision_model to a model the server serves that can see images", None)
|
|
347
347
|
else:
|
|
348
348
|
line("Vision", "off" if cfg["vision_model"] == "off" else vision.INSTALL_HINT, None)
|
|
349
|
+
if seer:
|
|
350
|
+
from lcode import clipboard
|
|
351
|
+
|
|
352
|
+
line("Clipboard", *clipboard.status())
|
|
349
353
|
if limits.get(model):
|
|
350
354
|
line(
|
|
351
355
|
"Limit",
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Paste from the clipboard with Ctrl+V, images included.
|
|
2
|
+
|
|
3
|
+
A terminal can only paste text, so a screenshot never reaches lcode that way. On Ctrl+V lcode reads the
|
|
4
|
+
clipboard itself: with wl-paste on Wayland, xclip on X11, and pngpaste or osascript on macOS. An image is
|
|
5
|
+
saved in lcode's state folder and goes into the prompt as `@path`, so it's attached like any other image;
|
|
6
|
+
anything else is pasted as text.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from lcode import config
|
|
20
|
+
|
|
21
|
+
TIMEOUT = 5 # seconds for a clipboard tool
|
|
22
|
+
IMAGE_TYPES = ("image/png", "image/jpeg", "image/webp", "image/gif")
|
|
23
|
+
SUFFIXES = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif"}
|
|
24
|
+
KEEP = 50 # pasted images kept; older ones are removed
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Paste:
|
|
29
|
+
image: Path | None = None # a saved image
|
|
30
|
+
text: str = "" # or text
|
|
31
|
+
problem: str = "" # why nothing could be pasted
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run(argv: list[str]) -> bytes | None:
|
|
35
|
+
try:
|
|
36
|
+
done = subprocess.run(argv, capture_output=True, timeout=TIMEOUT, check=False)
|
|
37
|
+
except (OSError, subprocess.SubprocessError):
|
|
38
|
+
return None
|
|
39
|
+
return done.stdout if done.returncode == 0 else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def folder() -> Path:
|
|
43
|
+
return config.STATE_DIR / "pasted"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def save(data: bytes, suffix: str) -> Path:
|
|
47
|
+
where = folder()
|
|
48
|
+
where.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
old = sorted(where.glob("pasted-*")) # the names sort by time
|
|
50
|
+
for path in old[: max(0, len(old) - KEEP + 1)]:
|
|
51
|
+
path.unlink(missing_ok=True)
|
|
52
|
+
now = time.time_ns()
|
|
53
|
+
stamp = f"{time.strftime('%Y%m%d-%H%M%S', time.localtime(now / 1e9))}-{now % 1_000_000_000:09d}"
|
|
54
|
+
path = where / f"pasted-{stamp}{suffix}"
|
|
55
|
+
n = 1
|
|
56
|
+
while path.exists():
|
|
57
|
+
n += 1
|
|
58
|
+
path = where / f"pasted-{stamp}-{n}{suffix}"
|
|
59
|
+
path.write_bytes(data)
|
|
60
|
+
return path
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def pick(types: list[str]) -> str | None:
|
|
64
|
+
return next((t for t in IMAGE_TYPES if t in types), None)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def paste() -> Paste:
|
|
68
|
+
"""Whatever is on the clipboard: an image (saved to a file) or text."""
|
|
69
|
+
if sys.platform == "darwin":
|
|
70
|
+
return paste_mac()
|
|
71
|
+
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("wl-paste"):
|
|
72
|
+
return paste_with(
|
|
73
|
+
["wl-paste", "--list-types"],
|
|
74
|
+
lambda kind: ["wl-paste", "--no-newline", "--type", kind],
|
|
75
|
+
["wl-paste", "--no-newline"],
|
|
76
|
+
)
|
|
77
|
+
if os.environ.get("DISPLAY") and shutil.which("xclip"):
|
|
78
|
+
return paste_with(
|
|
79
|
+
["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"],
|
|
80
|
+
lambda kind: ["xclip", "-selection", "clipboard", "-t", kind, "-o"],
|
|
81
|
+
["xclip", "-selection", "clipboard", "-o"],
|
|
82
|
+
)
|
|
83
|
+
return Paste(problem=status()[0])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def status() -> tuple[str, bool | None]:
|
|
87
|
+
"""Whether Ctrl+V can paste images here, and if not, what to do (for the paste and `lcode doctor`)."""
|
|
88
|
+
if sys.platform == "darwin":
|
|
89
|
+
return "Ctrl+V pastes images" + (" (with pngpaste)" if shutil.which("pngpaste") else ""), True
|
|
90
|
+
if os.environ.get("WAYLAND_DISPLAY"):
|
|
91
|
+
if shutil.which("wl-paste"):
|
|
92
|
+
return "Ctrl+V pastes images (with wl-paste)", True
|
|
93
|
+
return "To paste images with Ctrl+V, install wl-clipboard (for example `sudo apt install wl-clipboard`).", None
|
|
94
|
+
if os.environ.get("DISPLAY"):
|
|
95
|
+
if shutil.which("xclip"):
|
|
96
|
+
return "Ctrl+V pastes images (with xclip)", True
|
|
97
|
+
return "To paste images with Ctrl+V, install xclip (for example `sudo apt install xclip`).", None
|
|
98
|
+
return "There's no clipboard here (no desktop session), so give an image by its path.", None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def paste_with(list_types: list[str], get, get_text: list[str]) -> Paste:
|
|
102
|
+
types = (run(list_types) or b"").decode(errors="replace").split()
|
|
103
|
+
kind = pick(types)
|
|
104
|
+
if kind:
|
|
105
|
+
data = run(get(kind))
|
|
106
|
+
if data:
|
|
107
|
+
return Paste(image=save(data, SUFFIXES[kind]))
|
|
108
|
+
text = run(get_text)
|
|
109
|
+
if text:
|
|
110
|
+
return Paste(text=text.decode(errors="replace"))
|
|
111
|
+
return Paste(problem="The clipboard is empty.")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def paste_mac() -> Paste:
|
|
115
|
+
if shutil.which("pngpaste"):
|
|
116
|
+
data = run(["pngpaste", "-"])
|
|
117
|
+
if data:
|
|
118
|
+
return Paste(image=save(data, ".png"))
|
|
119
|
+
else:
|
|
120
|
+
target = folder() / ".clipboard.png"
|
|
121
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
target.unlink(missing_ok=True)
|
|
123
|
+
script = [
|
|
124
|
+
"try",
|
|
125
|
+
"set png to (the clipboard as «class PNGf»)",
|
|
126
|
+
f'set f to open for access POSIX file "{target}" with write permission',
|
|
127
|
+
"write png to f",
|
|
128
|
+
"close access f",
|
|
129
|
+
"end try",
|
|
130
|
+
]
|
|
131
|
+
run(["osascript", *[arg for line in script for arg in ("-e", line)]])
|
|
132
|
+
if target.exists() and target.stat().st_size:
|
|
133
|
+
data = target.read_bytes()
|
|
134
|
+
target.unlink(missing_ok=True)
|
|
135
|
+
return Paste(image=save(data, ".png"))
|
|
136
|
+
text = run(["pbpaste"])
|
|
137
|
+
if text:
|
|
138
|
+
return Paste(text=text.decode(errors="replace"))
|
|
139
|
+
return Paste(problem="The clipboard is empty.")
|