lcode-cli 0.23.0__tar.gz → 0.24.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.24.0}/CHANGELOG.md +18 -1
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/PKG-INFO +1 -1
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/quickstart.md +1 -1
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/usage.md +13 -2
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/agent.py +60 -12
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/cli.py +4 -0
- lcode_cli-0.24.0/src/lcode/clipboard.py +139 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/repl.py +22 -1
- lcode_cli-0.24.0/tests/test_clipboard.py +118 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_vision.py +31 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/.editorconfig +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/.github/dependabot.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/.gitignore +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/AGENTS.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/LICENSE +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/README.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/SECURITY.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/action.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/agents.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/assets/extra.css +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/assets/logo.svg +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/assets/social-preview.png +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/automation.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/bench.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/changelog.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/commands.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/configuration.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/contributing.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/editors.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/git.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/hooks.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/how-it-works.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/index.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/installation.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/lsp.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/mcp.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/memory.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/models.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/sandbox.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/search.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/servers.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/examples/README.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/examples/api/fix_tests.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/examples/commands/review.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/examples/skills/commit-message/SKILL.md +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/install.sh +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/mkdocs.yml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/overrides/main.html +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/pyproject.toml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/scripts/record_demo.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/scripts/social-preview.html +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/acp.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/action.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/api.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/backends.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/bench.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/bench_backend.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/codesearch.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/config.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/context.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/extensions.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/frontmatter.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/gitflow.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/hooks.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/jobs.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/limits.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/lsp.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/mcp/catalog.toml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/mcp/server.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/memory.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/models.toml +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/notify.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/permissions.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/planning.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/render.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/repeats.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/repomap.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/servers/__init__.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/servers/encord.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/servers/valohai.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/servers/xai.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/subagents.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/tools.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/update.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/vision.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/src/lcode/web.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/conftest.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/fake_lsp_server.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_acp.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_action.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_agent.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_api.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_backends.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_bench.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_catalog.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_cli.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_codesearch.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_config.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_context.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_extensions.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_gitflow.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_hooks.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_jobs.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_lsp.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_mcp.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_memory.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_permissions.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_plan_mode.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_repeats.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_repomap.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_servers.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_sessions.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_subagents.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_tools.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_update.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_web.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/tests/test_xai.py +0 -0
- {lcode_cli-0.23.0 → lcode_cli-0.24.0}/uv.lock +0 -0
|
@@ -6,6 +6,22 @@ All notable changes to lcode are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.24.0] - 2026-10-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Ctrl+V in lcode's prompt pastes a screenshot: lcode reads the clipboard (with `wl-paste` on
|
|
14
|
+
Wayland, `xclip` on X11, `pngpaste` or `osascript` on macOS), saves the image and attaches it as
|
|
15
|
+
`@path`. Other clipboard content is pasted as text. `lcode doctor` shows whether it works here.
|
|
16
|
+
- Image paths in a message are attached without `@`: absolute and `~` paths, quoted paths with
|
|
17
|
+
spaces, and `file://` paths dragged into the terminal. `@"path with spaces"` works for any file.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- An image path that doesn't exist is no longer ignored: lcode says so, and tells the model it can't
|
|
22
|
+
see that image, so it asks instead of guessing what the picture shows. See
|
|
23
|
+
[Images](https://nasser1941.github.io/lcode/usage/#images).
|
|
24
|
+
|
|
9
25
|
## [0.23.0] - 2026-10-10
|
|
10
26
|
|
|
11
27
|
### Added
|
|
@@ -508,7 +524,8 @@ First public release.
|
|
|
508
524
|
- `AGENTS.md` project instructions and `/init` to generate them.
|
|
509
525
|
- One-line installer for Linux and macOS.
|
|
510
526
|
|
|
511
|
-
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.
|
|
527
|
+
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.24.0...HEAD
|
|
528
|
+
[0.24.0]: https://github.com/nasser1941/lcode/compare/v0.23.0...v0.24.0
|
|
512
529
|
[0.23.0]: https://github.com/nasser1941/lcode/compare/v0.22.0...v0.23.0
|
|
513
530
|
[0.22.0]: https://github.com/nasser1941/lcode/compare/v0.21.0...v0.22.0
|
|
514
531
|
[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.24.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/
|
|
@@ -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
|
|
|
@@ -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.
|
|
@@ -965,30 +975,68 @@ class Agent:
|
|
|
965
975
|
)
|
|
966
976
|
|
|
967
977
|
def expand_mentions(self, text: str) -> str:
|
|
968
|
-
"""
|
|
969
|
-
|
|
970
|
-
|
|
978
|
+
"""Attach what the user's message points to: files and folders written as @path, image paths (with or
|
|
979
|
+
without @), and @name of an agent asks for that agent. An image path that doesn't exist is reported to
|
|
980
|
+
the user and the model, so the model doesn't guess what the image shows."""
|
|
981
|
+
attached: list[str] = []
|
|
982
|
+
seen: set[Path] = set()
|
|
983
|
+
question = MENTION.sub("", text)
|
|
984
|
+
|
|
985
|
+
def attach_image(p: Path) -> None:
|
|
986
|
+
try:
|
|
987
|
+
description = self.look(p, question)
|
|
988
|
+
who = f' described_by="{self.vision_model()}"'
|
|
989
|
+
except ToolError as e:
|
|
990
|
+
description, who = f"(lcode couldn't look at this image: {e})", ""
|
|
991
|
+
self.console.print(Text(f" ⎿ {e}", style="yellow"))
|
|
992
|
+
else:
|
|
993
|
+
self.console.print(Text(f" ⎿ looked at {self.tools.rel(p)}", style="dim"))
|
|
994
|
+
attached.append(f'<image path="{self.tools.rel(p)}"{who}>\n{description}\n</image>')
|
|
995
|
+
|
|
996
|
+
def missing_image(shown: str) -> None:
|
|
997
|
+
self.console.print(
|
|
998
|
+
Text(f" ⎿ {shown} doesn't exist, so the model can't see it. Paste images with Ctrl+V.", style="yellow")
|
|
999
|
+
)
|
|
1000
|
+
attached.append(
|
|
1001
|
+
f"[lcode: the user's message mentions the image {shown}, but there's no such file, so it wasn't "
|
|
1002
|
+
"attached and you can't see it. Don't guess what it shows: ask the user to paste it again (Ctrl+V "
|
|
1003
|
+
"in lcode's prompt) or to describe it.]"
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
for match in MENTION.finditer(text):
|
|
1007
|
+
ref = next(g for g in match.groups() if g)
|
|
971
1008
|
p = self.tools.resolve(ref)
|
|
1009
|
+
seen.add(p)
|
|
972
1010
|
name = ref.removeprefix("agent-")
|
|
973
1011
|
if not p.exists() and self.settings.subagents and name in self.agent_types():
|
|
974
1012
|
attached.append(f'[The user wants the {name} agent for this: call the agent tool with type "{name}".]')
|
|
975
1013
|
continue
|
|
976
1014
|
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>')
|
|
1015
|
+
attach_image(p)
|
|
986
1016
|
elif p.is_file() and not is_binary(p) and p.stat().st_size < 200_000:
|
|
987
1017
|
attached.append(f'<file path="{self.tools.rel(p)}">\n{p.read_text(errors="replace")}\n</file>')
|
|
988
1018
|
self.tools.read_mtimes[str(p)] = p.stat().st_mtime
|
|
989
1019
|
self.console.print(Text(f" ⎿ attached {self.tools.rel(p)}", style="dim"))
|
|
990
1020
|
elif p.is_dir():
|
|
991
1021
|
attached.append(f'<directory path="{self.tools.rel(p)}">\n{tree(p, 2)}\n</directory>')
|
|
1022
|
+
elif not p.exists() and vision.is_image(p):
|
|
1023
|
+
missing_image(ref)
|
|
1024
|
+
for match in IMAGE_PATH.finditer(text):
|
|
1025
|
+
quoted, raw = bool(match.group(2)), match.group(2) or match.group(3)
|
|
1026
|
+
if raw.startswith("@") or "://" in raw.removeprefix("file://"):
|
|
1027
|
+
continue # attached above, or a web address
|
|
1028
|
+
if raw.startswith("file://"):
|
|
1029
|
+
shown = urllib.parse.unquote(raw.removeprefix("file://"))
|
|
1030
|
+
else:
|
|
1031
|
+
shown = raw.replace("\\ ", " ")
|
|
1032
|
+
p = self.tools.resolve(shown)
|
|
1033
|
+
if p in seen:
|
|
1034
|
+
continue
|
|
1035
|
+
seen.add(p)
|
|
1036
|
+
if p.is_file():
|
|
1037
|
+
attach_image(p)
|
|
1038
|
+
elif quoted or raw.startswith(("/", "~", "./", "../", "file://")):
|
|
1039
|
+
missing_image(shown) # a name without a folder may be a file to create: no warning
|
|
992
1040
|
return text + ("\n\n" + "\n\n".join(attached) if attached else "")
|
|
993
1041
|
|
|
994
1042
|
# -- context management
|
|
@@ -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.")
|
|
@@ -14,6 +14,7 @@ import time
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
|
|
16
16
|
from prompt_toolkit import PromptSession
|
|
17
|
+
from prompt_toolkit.application import run_in_terminal
|
|
17
18
|
from prompt_toolkit.completion import Completer, Completion
|
|
18
19
|
from prompt_toolkit.formatted_text import HTML
|
|
19
20
|
from prompt_toolkit.history import FileHistory
|
|
@@ -26,7 +27,7 @@ from rich.rule import Rule
|
|
|
26
27
|
from rich.table import Table
|
|
27
28
|
from rich.text import Text
|
|
28
29
|
|
|
29
|
-
from lcode import __version__, backends, catalog, extensions, gitflow, limits, sessions, web
|
|
30
|
+
from lcode import __version__, backends, catalog, clipboard, extensions, gitflow, limits, sessions, web
|
|
30
31
|
from lcode import context as context_tools
|
|
31
32
|
from lcode import memory as memory_notes
|
|
32
33
|
from lcode.agent import AUTO_COMPACT_RATIO, INIT_PROMPT, Agent
|
|
@@ -101,6 +102,13 @@ class InputCompleter(Completer):
|
|
|
101
102
|
yield Completion(completion, start_position=-len(fragment))
|
|
102
103
|
|
|
103
104
|
|
|
105
|
+
def mention(path: Path) -> str:
|
|
106
|
+
"""How a file goes into the prompt: @path, with ~ for the home folder, in quotes if it has spaces."""
|
|
107
|
+
home = Path.home()
|
|
108
|
+
shown = f"~/{path.relative_to(home)}" if path.is_relative_to(home) else str(path)
|
|
109
|
+
return f'@"{shown}"' if " " in shown else f"@{shown}"
|
|
110
|
+
|
|
111
|
+
|
|
104
112
|
def build_session(agent: Agent) -> PromptSession:
|
|
105
113
|
kb = KeyBindings()
|
|
106
114
|
|
|
@@ -119,6 +127,19 @@ def build_session(agent: Agent) -> PromptSession:
|
|
|
119
127
|
def _newline(event):
|
|
120
128
|
event.current_buffer.insert_text("\n")
|
|
121
129
|
|
|
130
|
+
@kb.add("c-v")
|
|
131
|
+
def _paste(event):
|
|
132
|
+
"""Paste the clipboard: an image becomes @path (terminals can't paste images), anything else is text."""
|
|
133
|
+
got = clipboard.paste()
|
|
134
|
+
buf = event.current_buffer
|
|
135
|
+
if got.image:
|
|
136
|
+
before = buf.document.char_before_cursor
|
|
137
|
+
buf.insert_text(("" if not before or before.isspace() else " ") + mention(got.image) + " ")
|
|
138
|
+
elif got.text:
|
|
139
|
+
buf.insert_text(got.text.replace("\r\n", "\n"))
|
|
140
|
+
else:
|
|
141
|
+
run_in_terminal(lambda: agent.console.print(Text(f" {got.problem}", style="yellow")))
|
|
142
|
+
|
|
122
143
|
@kb.add("s-tab")
|
|
123
144
|
def _cycle_mode(event):
|
|
124
145
|
agent.perms.cycle()
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import stat
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from lcode import clipboard, config, repl
|
|
7
|
+
|
|
8
|
+
PNG = b"\x89PNG\r\n\x1a\nfake"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def tool(bin_dir, name, script):
|
|
12
|
+
path = bin_dir / name
|
|
13
|
+
path.write_text("#!/bin/sh\n" + script)
|
|
14
|
+
path.chmod(path.stat().st_mode | stat.S_IEXEC)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def desktop(tmp_path, monkeypatch):
|
|
19
|
+
"""No clipboard tools and no desktop session, until a test adds them."""
|
|
20
|
+
bin_dir = tmp_path / "bin"
|
|
21
|
+
bin_dir.mkdir()
|
|
22
|
+
for name in ("sh", "cat", "printf"):
|
|
23
|
+
os.symlink(f"/bin/{name}" if os.path.exists(f"/bin/{name}") else f"/usr/bin/{name}", bin_dir / name)
|
|
24
|
+
monkeypatch.setenv("PATH", str(bin_dir))
|
|
25
|
+
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
|
|
26
|
+
monkeypatch.delenv("DISPLAY", raising=False)
|
|
27
|
+
monkeypatch.setattr(clipboard.sys, "platform", "linux")
|
|
28
|
+
monkeypatch.setattr(config, "STATE_DIR", tmp_path / "state")
|
|
29
|
+
(tmp_path / "shot.png").write_bytes(PNG)
|
|
30
|
+
return bin_dir
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_an_image_on_wayland_is_saved(desktop, tmp_path, monkeypatch):
|
|
34
|
+
monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0")
|
|
35
|
+
tool(
|
|
36
|
+
desktop,
|
|
37
|
+
"wl-paste",
|
|
38
|
+
f'if [ "$1" = "--list-types" ]; then printf "text/plain\\nimage/png\\n"; '
|
|
39
|
+
f'elif [ "$2" = "--type" ] && [ "$3" = "image/png" ]; then cat {tmp_path / "shot.png"}; '
|
|
40
|
+
"else exit 1; fi\n",
|
|
41
|
+
)
|
|
42
|
+
got = clipboard.paste()
|
|
43
|
+
assert got.image is not None and got.image.read_bytes() == PNG
|
|
44
|
+
assert got.image.parent == tmp_path / "state" / "pasted" and got.image.suffix == ".png"
|
|
45
|
+
assert clipboard.paste().image != got.image # a second paste doesn't overwrite the first
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_text_on_x11_is_pasted_as_text(desktop, monkeypatch):
|
|
49
|
+
monkeypatch.setenv("DISPLAY", ":0")
|
|
50
|
+
tool(
|
|
51
|
+
desktop,
|
|
52
|
+
"xclip",
|
|
53
|
+
'if [ "$4" = "TARGETS" ]; then printf "UTF8_STRING\\nTARGETS\\n"; else printf "hello world"; fi\n',
|
|
54
|
+
)
|
|
55
|
+
got = clipboard.paste()
|
|
56
|
+
assert got.image is None and got.text == "hello world"
|
|
57
|
+
assert clipboard.status() == ("Ctrl+V pastes images (with xclip)", True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_missing_tools_say_what_to_install(desktop, monkeypatch):
|
|
61
|
+
assert "no clipboard here" in clipboard.paste().problem
|
|
62
|
+
monkeypatch.setenv("DISPLAY", ":0")
|
|
63
|
+
assert "install xclip" in clipboard.paste().problem
|
|
64
|
+
monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0")
|
|
65
|
+
assert "install wl-clipboard" in clipboard.paste().problem and clipboard.status()[1] is None
|
|
66
|
+
tool(desktop, "wl-paste", "exit 1\n")
|
|
67
|
+
assert clipboard.paste().problem == "The clipboard is empty."
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_an_image_on_a_mac(desktop, tmp_path, monkeypatch):
|
|
71
|
+
monkeypatch.setattr(clipboard.sys, "platform", "darwin")
|
|
72
|
+
tool(desktop, "pngpaste", f'[ "$1" = "-" ] && cat {tmp_path / "shot.png"}\n')
|
|
73
|
+
assert clipboard.paste().image.read_bytes() == PNG
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_a_mac_without_pngpaste_asks_osascript(desktop, tmp_path, monkeypatch):
|
|
77
|
+
monkeypatch.setattr(clipboard.sys, "platform", "darwin")
|
|
78
|
+
target = tmp_path / "state" / "pasted" / ".clipboard.png"
|
|
79
|
+
tool(desktop, "osascript", f"cat {tmp_path / 'shot.png'} > {target}\n")
|
|
80
|
+
got = clipboard.paste()
|
|
81
|
+
assert got.image.read_bytes() == PNG and not target.exists()
|
|
82
|
+
tool(desktop, "osascript", "exit 0\n")
|
|
83
|
+
tool(desktop, "pbpaste", "printf 'some text'\n")
|
|
84
|
+
assert clipboard.paste().text == "some text"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_old_pastes_are_cleaned_up(desktop, monkeypatch):
|
|
88
|
+
monkeypatch.setattr(clipboard, "KEEP", 3)
|
|
89
|
+
paths = [clipboard.save(PNG, ".png") for _ in range(5)]
|
|
90
|
+
left = sorted((config.STATE_DIR / "pasted").glob("pasted-*"))
|
|
91
|
+
assert len(left) == 3 and paths[-1] in left and paths[0] not in left
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_a_pasted_image_goes_into_the_prompt_as_a_mention(tmp_path, monkeypatch):
|
|
95
|
+
monkeypatch.setattr(repl.Path, "home", lambda: tmp_path)
|
|
96
|
+
assert repl.mention(tmp_path / "state" / "pasted-1.png") == "@~/state/pasted-1.png"
|
|
97
|
+
assert repl.mention(tmp_path / "My Shots" / "a.png") == '@"~/My Shots/a.png"'
|
|
98
|
+
assert repl.mention(tmp_path.parent / "x.png") == f"@{tmp_path.parent / 'x.png'}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_ctrl_v_puts_the_image_or_text_in_the_prompt(make_agent, monkeypatch, tmp_path):
|
|
102
|
+
from types import SimpleNamespace
|
|
103
|
+
|
|
104
|
+
from prompt_toolkit.buffer import Buffer
|
|
105
|
+
from prompt_toolkit.keys import Keys
|
|
106
|
+
|
|
107
|
+
monkeypatch.setattr(repl, "STATE_DIR", tmp_path)
|
|
108
|
+
session = repl.build_session(make_agent())
|
|
109
|
+
(binding,) = session.key_bindings.get_bindings_for_keys((Keys.ControlV,))
|
|
110
|
+
buffer = Buffer(multiline=True)
|
|
111
|
+
buffer.insert_text("why is this broken?")
|
|
112
|
+
shot = tmp_path / "pasted-1.png"
|
|
113
|
+
monkeypatch.setattr(repl.clipboard, "paste", lambda: repl.clipboard.Paste(image=shot))
|
|
114
|
+
binding.handler(SimpleNamespace(current_buffer=buffer))
|
|
115
|
+
assert buffer.text == f"why is this broken? {repl.mention(shot)} "
|
|
116
|
+
monkeypatch.setattr(repl.clipboard, "paste", lambda: repl.clipboard.Paste(text="line 1\r\nline 2"))
|
|
117
|
+
binding.handler(SimpleNamespace(current_buffer=buffer))
|
|
118
|
+
assert buffer.text.endswith(" line 1\nline 2")
|
|
@@ -102,3 +102,34 @@ def test_view_image_respects_the_sandbox(make_agent, tmp_path_factory):
|
|
|
102
102
|
outside.write_bytes(PNG)
|
|
103
103
|
agent = seeing(make_agent(sandbox="docker"), "qwen3.6:35b-a3b-coding")
|
|
104
104
|
assert agent.tools.run("view_image", {"path": str(outside)}).startswith("Error:")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_image_paths_are_attached_without_an_at(make_agent, repo):
|
|
108
|
+
(repo / "shots").mkdir()
|
|
109
|
+
(repo / "shots" / "My Shot.png").write_bytes(PNG)
|
|
110
|
+
(repo / "bug.png").write_bytes(PNG)
|
|
111
|
+
agent = seeing(make_agent(), "qwen3.6:35b-a3b-coding")
|
|
112
|
+
text = agent.expand_mentions(f"compare {repo / 'bug.png'} with './shots/My Shot.png', and @bug.png again")
|
|
113
|
+
assert text.count("<image") == 2
|
|
114
|
+
assert '<image path="bug.png"' in text and '<image path="shots/My Shot.png"' in text
|
|
115
|
+
uri = agent.expand_mentions(f"dragged in: file://{repo}/shots/My%20Shot.png")
|
|
116
|
+
assert '<image path="shots/My Shot.png"' in uri
|
|
117
|
+
quoted = agent.expand_mentions('see @"shots/My Shot.png"')
|
|
118
|
+
assert '<image path="shots/My Shot.png"' in quoted
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_a_missing_image_is_reported_not_guessed(make_agent):
|
|
122
|
+
agent = seeing(make_agent([reply("Please paste it again.")]), "qwen3.6:35b-a3b-coding")
|
|
123
|
+
agent.run_turn("now we see something like /tmp/pasted-image-.png")
|
|
124
|
+
sent = agent.messages[1]["content"]
|
|
125
|
+
assert "[lcode: the user's message mentions the image /tmp/pasted-image-.png, but there's no such file" in sent
|
|
126
|
+
assert "Don't guess what it shows" in sent and "<image" not in sent
|
|
127
|
+
assert "/tmp/pasted-image-.png doesn't exist, so the model can't see it" in output(agent)
|
|
128
|
+
assert agent.ollama.chats == [] # nothing to look at
|
|
129
|
+
assert "no such file" in agent.expand_mentions("look at @gone.png")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_image_names_without_a_folder_may_be_new_files(make_agent):
|
|
133
|
+
agent = make_agent()
|
|
134
|
+
text = "rename logo.png to icon.png and see https://example.com/banner.png"
|
|
135
|
+
assert agent.expand_mentions(text) == text
|
|
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
|
|
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
|
|
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
|
|
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
|