lcode-cli 0.2.0__tar.gz → 0.2.2__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.2.0 → lcode_cli-0.2.2}/CHANGELOG.md +21 -1
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/PKG-INFO +11 -9
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/README.md +10 -8
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/SECURITY.md +1 -1
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/docs/index.md +2 -2
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/docs/models.md +18 -4
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/docs/quickstart.md +1 -1
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/docs/usage.md +1 -2
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/src/lcode/agent.py +41 -12
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/src/lcode/permissions.py +1 -1
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/src/lcode/repl.py +113 -25
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/tests/test_agent.py +12 -0
- lcode_cli-0.2.2/tests/test_context_picker.py +73 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/tests/test_permissions.py +9 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/tests/test_tools.py +11 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/.editorconfig +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/.github/dependabot.yml +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/.gitignore +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/AGENTS.md +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/LICENSE +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/docs/assets/extra.css +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/docs/assets/logo.svg +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/docs/assets/social-preview.png +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/docs/changelog.md +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/docs/configuration.md +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/docs/contributing.md +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/docs/how-it-works.md +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/docs/installation.md +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/install.sh +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/mkdocs.yml +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/overrides/main.html +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/pyproject.toml +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/scripts/record_demo.py +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/scripts/social-preview.html +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/src/lcode/cli.py +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/src/lcode/config.py +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/src/lcode/limits.py +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/src/lcode/models.toml +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/src/lcode/render.py +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/src/lcode/tools.py +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/src/lcode/web.py +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/tests/conftest.py +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/tests/test_catalog.py +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/tests/test_cli.py +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/tests/test_config.py +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/tests/test_sessions.py +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/tests/test_web.py +0 -0
- {lcode_cli-0.2.0 → lcode_cli-0.2.2}/uv.lock +0 -0
|
@@ -6,6 +6,24 @@ All notable changes to lcode are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.2] - 2026-09-30
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `/context` now lets you change the context window mid-session: it lists the sizes the model
|
|
14
|
+
supports with the memory each needs and whether it fits on the GPU, and you pick one (or type
|
|
15
|
+
`/context 128k`). If the conversation is too long for a smaller size, lcode summarizes it first.
|
|
16
|
+
`/ctx` is a shortcut for the same command.
|
|
17
|
+
|
|
18
|
+
## [0.2.1] - 2026-09-29
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- File edits and writes failed with "IndexError: list index out of range" in the default `ask`
|
|
23
|
+
permission mode, a regression in 0.2.0. `auto-edit` and `yolo` modes were not affected.
|
|
24
|
+
- Slow models no longer look stuck: the status line keeps counting the elapsed time while the model
|
|
25
|
+
thinks, answers or silently writes a long file into a tool call, and reminds you that Ctrl+C stops it.
|
|
26
|
+
|
|
9
27
|
## [0.2.0] - 2026-09-29
|
|
10
28
|
|
|
11
29
|
### Added
|
|
@@ -98,7 +116,9 @@ First public release.
|
|
|
98
116
|
- `AGENTS.md` project instructions and `/init` to generate them.
|
|
99
117
|
- One-line installer for Linux and macOS.
|
|
100
118
|
|
|
101
|
-
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.2.
|
|
119
|
+
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.2.2...HEAD
|
|
120
|
+
[0.2.2]: https://github.com/nasser1941/lcode/compare/v0.2.1...v0.2.2
|
|
121
|
+
[0.2.1]: https://github.com/nasser1941/lcode/compare/v0.2.0...v0.2.1
|
|
102
122
|
[0.2.0]: https://github.com/nasser1941/lcode/compare/v0.1.2...v0.2.0
|
|
103
123
|
[0.1.2]: https://github.com/nasser1941/lcode/compare/v0.1.1...v0.1.2
|
|
104
124
|
[0.1.1]: https://github.com/nasser1941/lcode/compare/v0.1.0...v0.1.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
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/
|
|
@@ -124,7 +124,7 @@ lcode
|
|
|
124
124
|
| `lcode --model qwen3.5-9b --context 128k` | pick a model and context window for this session |
|
|
125
125
|
| `lcode models` / `lcode doctor` | what fits this machine / check the installation |
|
|
126
126
|
| `/rename`, `/resume` | name the current session, resume a saved one |
|
|
127
|
-
| `/model`, `/
|
|
127
|
+
| `/model`, `/context`, `/compact`, `/help` | switch model, resize the context window (pick from a list), summarize, list commands |
|
|
128
128
|
|
|
129
129
|
## Models
|
|
130
130
|
|
|
@@ -150,8 +150,8 @@ What `lcode setup` picks for common machines:
|
|
|
150
150
|
| NVIDIA 8–24 GB + 32 GB RAM | qwen3.6-35b | 256K |
|
|
151
151
|
| NVIDIA 8 GB + 16 GB RAM | gpt-oss-20b | 64K |
|
|
152
152
|
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
At 256K context the default model generates 50–55 tokens/s on an RTX 4080 Laptop GPU (12 GB) and
|
|
154
|
+
45 tokens/s on an M4 Pro Mac with 48 GB. See
|
|
155
155
|
[Models & context windows](https://nasser1941.github.io/lcode/models/) for memory estimates and
|
|
156
156
|
tuning.
|
|
157
157
|
|
|
@@ -165,11 +165,13 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
|
|
|
165
165
|
|
|
166
166
|
## Roadmap
|
|
167
167
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
-
|
|
171
|
-
-
|
|
172
|
-
-
|
|
168
|
+
See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
|
|
169
|
+
|
|
170
|
+
- [`/undo` and automatic checkpoints](https://github.com/nasser1941/lcode/issues/4)
|
|
171
|
+
- [`lcode bench`: score models on your own hardware](https://github.com/nasser1941/lcode/issues/5)
|
|
172
|
+
- [MCP tool servers](https://github.com/nasser1941/lcode/issues/6)
|
|
173
|
+
- [Homebrew formula](https://github.com/nasser1941/lcode/issues/8) and an [optional sandbox for shell commands](https://github.com/nasser1941/lcode/issues/9)
|
|
174
|
+
- More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
|
|
173
175
|
|
|
174
176
|
## Contributing
|
|
175
177
|
|
|
@@ -91,7 +91,7 @@ lcode
|
|
|
91
91
|
| `lcode --model qwen3.5-9b --context 128k` | pick a model and context window for this session |
|
|
92
92
|
| `lcode models` / `lcode doctor` | what fits this machine / check the installation |
|
|
93
93
|
| `/rename`, `/resume` | name the current session, resume a saved one |
|
|
94
|
-
| `/model`, `/
|
|
94
|
+
| `/model`, `/context`, `/compact`, `/help` | switch model, resize the context window (pick from a list), summarize, list commands |
|
|
95
95
|
|
|
96
96
|
## Models
|
|
97
97
|
|
|
@@ -117,8 +117,8 @@ What `lcode setup` picks for common machines:
|
|
|
117
117
|
| NVIDIA 8–24 GB + 32 GB RAM | qwen3.6-35b | 256K |
|
|
118
118
|
| NVIDIA 8 GB + 16 GB RAM | gpt-oss-20b | 64K |
|
|
119
119
|
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
At 256K context the default model generates 50–55 tokens/s on an RTX 4080 Laptop GPU (12 GB) and
|
|
121
|
+
45 tokens/s on an M4 Pro Mac with 48 GB. See
|
|
122
122
|
[Models & context windows](https://nasser1941.github.io/lcode/models/) for memory estimates and
|
|
123
123
|
tuning.
|
|
124
124
|
|
|
@@ -132,11 +132,13 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
|
|
|
132
132
|
|
|
133
133
|
## Roadmap
|
|
134
134
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
-
|
|
138
|
-
-
|
|
139
|
-
-
|
|
135
|
+
See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
|
|
136
|
+
|
|
137
|
+
- [`/undo` and automatic checkpoints](https://github.com/nasser1941/lcode/issues/4)
|
|
138
|
+
- [`lcode bench`: score models on your own hardware](https://github.com/nasser1941/lcode/issues/5)
|
|
139
|
+
- [MCP tool servers](https://github.com/nasser1941/lcode/issues/6)
|
|
140
|
+
- [Homebrew formula](https://github.com/nasser1941/lcode/issues/8) and an [optional sandbox for shell commands](https://github.com/nasser1941/lcode/issues/9)
|
|
141
|
+
- More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
|
|
140
142
|
|
|
141
143
|
## Contributing
|
|
142
144
|
|
|
@@ -82,8 +82,8 @@ until the task is done, asking before it changes anything.
|
|
|
82
82
|
|
|
83
83
|
| Platform | Recommended hardware | Default choice |
|
|
84
84
|
|---|---|---|
|
|
85
|
-
| **Ubuntu / Linux** with NVIDIA GPU | 12 GB+ VRAM and 32 GB RAM | Qwen3.6 35B-A3B at 256K context |
|
|
86
|
-
| **macOS on Apple Silicon** (M1–M4) | 48 GB+ unified memory | Qwen3.6 35B-A3B at 256K context |
|
|
85
|
+
| **Ubuntu / Linux** with NVIDIA GPU | 12 GB+ VRAM and 32 GB RAM | Qwen3.6 35B-A3B at 256K context (50–55 tok/s on 12 GB) |
|
|
86
|
+
| **macOS on Apple Silicon** (M1–M4) | 48 GB+ unified memory | Qwen3.6 35B-A3B at 256K context (45 tok/s on an M4 Pro) |
|
|
87
87
|
| Smaller machines (16–24 GB Mac, 8 GB GPU) | — | Qwen3.5 9B / 4B with a smaller context |
|
|
88
88
|
|
|
89
89
|
See [Models & context windows](models.md) for the full catalog and how lcode sizes models to your
|
|
@@ -91,9 +91,20 @@ lcode --context 64k # this session only
|
|
|
91
91
|
lcode config set context 128k # change the saved default
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
+
Inside a session, `/context` lists the sizes the model supports, with the memory each needs and
|
|
95
|
+
whether it fits on the GPU, and lets you pick one (or type `/context 128k` directly). The model
|
|
96
|
+
reloads with the new size on your next message; if the conversation is too long for a smaller size,
|
|
97
|
+
lcode summarizes it first.
|
|
98
|
+
|
|
94
99
|
```text
|
|
95
|
-
❯ /
|
|
96
|
-
|
|
100
|
+
❯ /context
|
|
101
|
+
Context window for lcode-qwen3.6-35b
|
|
102
|
+
# Size Memory · fit
|
|
103
|
+
1 16K ~22 GB · GPU + RAM
|
|
104
|
+
2 32K ~23 GB · GPU + RAM
|
|
105
|
+
…
|
|
106
|
+
5 256K ~28 GB · GPU + RAM current, recommended
|
|
107
|
+
Choose a number, a size like 96k, or press Enter to keep 256K: 2
|
|
97
108
|
```
|
|
98
109
|
|
|
99
110
|
Sizes accept `k` and `m` suffixes (`128k` = 131,072 tokens, `1m` = 1,048,576). When the window is
|
|
@@ -138,7 +149,7 @@ results on your machine are welcome in the
|
|
|
138
149
|
| Mac with M4, 16 GB | ~11 GB | qwen3.5-9b | 64K | fast |
|
|
139
150
|
| Mac with M4 / M4 Pro, 24 GB | ~16 GB | qwen3.5-9b | 256K | fast |
|
|
140
151
|
| Mac with M4 Pro / M4 Max, 36 GB | ~24 GB | qwen3.6-35b | 64K | fast |
|
|
141
|
-
| Mac with M4 Pro, 48 GB | ~36 GB | qwen3.6-35b | 256K | fast |
|
|
152
|
+
| Mac with M4 Pro, 48 GB | ~36 GB | qwen3.6-35b | 256K | fast (measured: 45 tok/s) |
|
|
142
153
|
| Mac with M4 Max, 64–128 GB | 48–96 GB | qwen3.6-35b | 256K | fast |
|
|
143
154
|
| NVIDIA 8 GB + 16 GB RAM | ~16 GB | gpt-oss-20b | 64K | good (experts in RAM) |
|
|
144
155
|
| NVIDIA 8–16 GB + 32 GB RAM | 32–40 GB | qwen3.6-35b | 256K | good (experts in RAM) |
|
|
@@ -200,7 +211,10 @@ The small models on the same GPU (a bug-fix task: read, run the failing tests, e
|
|
|
200
211
|
Dense models slow down about 3x once they no longer fit in VRAM, which is why lcode sizes their
|
|
201
212
|
context to stay on the GPU.
|
|
202
213
|
|
|
203
|
-
Apple
|
|
214
|
+
On a Mac with an **Apple M4 Pro and 48 GB** of unified memory (reported by a user, Ollama 0.34), lcode
|
|
215
|
+
picks the same `qwen3.6-35b` at 256K context and generates **45 tokens/s**, running entirely on the
|
|
216
|
+
GPU's share of unified memory. Results from other Macs are very welcome in the
|
|
217
|
+
[model reports](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml).
|
|
204
218
|
|
|
205
219
|
### Tips for Macs
|
|
206
220
|
|
|
@@ -50,8 +50,7 @@ reasoning is on.
|
|
|
50
50
|
| `/rename NAME` | Name the current session so it's easy to find later |
|
|
51
51
|
| `/resume [SESSION]` | Resume a saved session: pick from a list, or give its number or name; `/resume all` lists every folder |
|
|
52
52
|
| `/compact [focus]` | Summarize the conversation to free context |
|
|
53
|
-
| `/context` | Show how full the context window is |
|
|
54
|
-
| `/ctx [size]` | Show or change the context window |
|
|
53
|
+
| `/context [size]` | Show how full the context window is and change its size: pick from a list with memory estimates, or give a size like `/context 128k` (`/ctx` is a shortcut) |
|
|
55
54
|
| `/model [name]`, `/models` | Switch model, list models |
|
|
56
55
|
| `/think [on\|off]` | Toggle reasoning |
|
|
57
56
|
| `/verbose` | Toggle showing the reasoning text |
|
|
@@ -101,6 +101,29 @@ def git_info(cwd: Path) -> str:
|
|
|
101
101
|
return "unknown"
|
|
102
102
|
|
|
103
103
|
|
|
104
|
+
class Progress:
|
|
105
|
+
"""The status line under a running request.
|
|
106
|
+
|
|
107
|
+
It is re-rendered on every spinner frame, so the elapsed time keeps moving even when no text
|
|
108
|
+
arrives, for example while the model writes a long file into a tool call.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self) -> None:
|
|
112
|
+
self.started = self.last_chunk = time.time()
|
|
113
|
+
self.phase = "Thinking"
|
|
114
|
+
self.chars = 0
|
|
115
|
+
|
|
116
|
+
def update(self, phase: str, text: str) -> None:
|
|
117
|
+
self.phase, self.chars, self.last_chunk = phase, self.chars + len(text), time.time()
|
|
118
|
+
|
|
119
|
+
def __rich__(self) -> Text:
|
|
120
|
+
now = time.time()
|
|
121
|
+
phase = self.phase if now - self.last_chunk < 3 else "Working"
|
|
122
|
+
return Text.from_markup(
|
|
123
|
+
f"[cyan]{phase}…[/] [dim]({self.chars // 4} tokens, {now - self.started:.0f}s · Ctrl+C to stop)[/]"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
104
127
|
@dataclass
|
|
105
128
|
class Settings:
|
|
106
129
|
model: str # Ollama model name to call
|
|
@@ -301,35 +324,41 @@ class Agent:
|
|
|
301
324
|
"""Stream one model response, rendering thinking/content live. Returns the assistant message."""
|
|
302
325
|
content, thinking, tool_calls, final = "", "", [], {}
|
|
303
326
|
md = MarkdownStreamer(self.console)
|
|
304
|
-
|
|
305
|
-
status
|
|
306
|
-
|
|
327
|
+
progress = Progress()
|
|
328
|
+
status = None
|
|
329
|
+
printed_thinking = False
|
|
330
|
+
|
|
331
|
+
def start_spinner() -> None:
|
|
332
|
+
nonlocal status
|
|
333
|
+
if status is None:
|
|
334
|
+
status = self.console.status(progress, spinner="dots")
|
|
335
|
+
status.start()
|
|
307
336
|
|
|
308
337
|
def stop_spinner() -> None:
|
|
309
|
-
nonlocal
|
|
310
|
-
if
|
|
338
|
+
nonlocal status
|
|
339
|
+
if status is not None:
|
|
311
340
|
status.stop()
|
|
312
|
-
|
|
341
|
+
status = None
|
|
313
342
|
|
|
343
|
+
start_spinner()
|
|
314
344
|
try:
|
|
315
345
|
for chunk in self.chat(self.messages, self.tool_schemas(), self.settings.think):
|
|
316
346
|
msg = chunk.get("message", {})
|
|
317
347
|
if msg.get("thinking"):
|
|
318
348
|
thinking += msg["thinking"]
|
|
349
|
+
progress.update("Thinking", msg["thinking"])
|
|
319
350
|
if self.settings.show_thinking:
|
|
320
|
-
stop_spinner()
|
|
351
|
+
stop_spinner() # raw text is printed as it streams
|
|
321
352
|
self.console.print(Text(msg["thinking"], style="dim italic"), end="")
|
|
322
353
|
printed_thinking = True
|
|
323
|
-
else:
|
|
324
|
-
status.update(
|
|
325
|
-
f"[cyan]Thinking…[/] [dim]({len(thinking) // 4} tokens, {time.time() - t0:.0f}s)[/]"
|
|
326
|
-
)
|
|
327
354
|
if msg.get("content"):
|
|
328
|
-
stop_spinner()
|
|
329
355
|
if printed_thinking:
|
|
330
356
|
self.console.print("\n")
|
|
331
357
|
printed_thinking = False
|
|
332
358
|
content += msg["content"]
|
|
359
|
+
progress.update("Writing", msg["content"])
|
|
360
|
+
# Finished markdown blocks print above the spinner, which keeps running meanwhile.
|
|
361
|
+
start_spinner()
|
|
333
362
|
md.feed(msg["content"])
|
|
334
363
|
if msg.get("tool_calls"):
|
|
335
364
|
tool_calls.extend(msg["tool_calls"])
|
|
@@ -71,7 +71,7 @@ class Permissions:
|
|
|
71
71
|
if self.mode == "yolo" or key in self.always or (kind == "edit" and self.mode == "auto-edit"):
|
|
72
72
|
return True, ""
|
|
73
73
|
self.console.print(Panel(body, title=title, title_align="left", border_style="yellow"))
|
|
74
|
-
scope =
|
|
74
|
+
scope = "file edits" if kind == "edit" else key.split(":", 1)[-1]
|
|
75
75
|
try:
|
|
76
76
|
answer = input(f" Allow? [y]es / [a]lways for '{scope}' this session / [n]o (+ optional reason): ")
|
|
77
77
|
except EOFError:
|
|
@@ -21,7 +21,7 @@ from rich.table import Table
|
|
|
21
21
|
from rich.text import Text
|
|
22
22
|
|
|
23
23
|
from lcode import __version__, catalog, limits, sessions, web
|
|
24
|
-
from lcode.agent import INIT_PROMPT, Agent
|
|
24
|
+
from lcode.agent import AUTO_COMPACT_RATIO, INIT_PROMPT, Agent
|
|
25
25
|
from lcode.catalog import MIN_USEFUL_CONTEXT
|
|
26
26
|
from lcode.config import PERMISSION_MODES, STATE_DIR, ConfigError, format_tokens, parse_context
|
|
27
27
|
from lcode.hardware import Hardware
|
|
@@ -35,8 +35,8 @@ COMMANDS = {
|
|
|
35
35
|
"/rename": "Name this session so you can find it later, e.g. /rename auth refactor",
|
|
36
36
|
"/resume": "Resume a saved session: pick from a list, or /resume <number|name> (/resume all: every folder)",
|
|
37
37
|
"/compact": "Summarize the conversation to free context (optional: what to focus on)",
|
|
38
|
-
"/context": "Show context
|
|
39
|
-
"/ctx": "
|
|
38
|
+
"/context": "Show context usage and change the window size: pick from a list, or /context 128k",
|
|
39
|
+
"/ctx": "Shortcut for /context",
|
|
40
40
|
"/model": "Show or switch model, e.g. /model qwen3.5-9b",
|
|
41
41
|
"/models": "List models and how they fit this machine",
|
|
42
42
|
"/think": "Toggle model reasoning on/off",
|
|
@@ -145,6 +145,114 @@ def banner(agent: Agent) -> None:
|
|
|
145
145
|
)
|
|
146
146
|
|
|
147
147
|
|
|
148
|
+
CONTEXT_CHOICES = (16384, 32768, 65536, 131072, 262144, 524288, 1048576)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def context_options(agent: Agent, hardware: Hardware) -> list[dict]:
|
|
152
|
+
"""The context sizes this model supports, with how each one fits this machine."""
|
|
153
|
+
s = agent.settings
|
|
154
|
+
spec = catalog.find(s.model)
|
|
155
|
+
try:
|
|
156
|
+
max_ctx = agent.ollama.max_context(s.model) or (spec.max_context if spec else None)
|
|
157
|
+
except OllamaError:
|
|
158
|
+
max_ctx = spec.max_context if spec else None
|
|
159
|
+
sizes = {c for c in CONTEXT_CHOICES if not max_ctx or c <= max_ctx} | {s.context}
|
|
160
|
+
if max_ctx:
|
|
161
|
+
sizes.add(max_ctx)
|
|
162
|
+
learned = limits.get(s.model)
|
|
163
|
+
recommended = limits.cap(s.model, spec.fit(hardware)[0]) if spec and spec.fit(hardware)[0] else None
|
|
164
|
+
options = []
|
|
165
|
+
for size in sorted(sizes):
|
|
166
|
+
if spec:
|
|
167
|
+
memory = spec.memory_gib(size)
|
|
168
|
+
if hardware.unified:
|
|
169
|
+
fit = "fits" if memory <= hardware.budget_gib - catalog.HEADROOM_GIB else "too large"
|
|
170
|
+
elif hardware.gpu and memory <= hardware.vram_gib:
|
|
171
|
+
fit = "on GPU"
|
|
172
|
+
elif memory <= hardware.budget_gib - catalog.HEADROOM_GIB:
|
|
173
|
+
fit = "GPU + RAM" if spec.moe else "GPU + RAM, slow"
|
|
174
|
+
else:
|
|
175
|
+
fit = "too large"
|
|
176
|
+
note = f"~{memory:.0f} GB · {fit}"
|
|
177
|
+
else:
|
|
178
|
+
note = "no memory estimate for this model"
|
|
179
|
+
if learned and size > learned:
|
|
180
|
+
note += " · ran out of memory here before"
|
|
181
|
+
options.append({"size": size, "note": note, "current": size == s.context, "recommended": size == recommended})
|
|
182
|
+
return options
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
|
|
186
|
+
"""Show context usage and change the window size, from a list or directly (/context 128k)."""
|
|
187
|
+
c, s = agent.console, agent.settings
|
|
188
|
+
c.print(
|
|
189
|
+
f"In use: {format_tokens(agent.ctx_used)} of {format_tokens(s.context)} tokens "
|
|
190
|
+
f"({100 * agent.ctx_used / s.context:.0f}%) in {len(agent.messages)} message"
|
|
191
|
+
f"{'' if len(agent.messages) == 1 else 's'}; auto-compacts at 85%."
|
|
192
|
+
)
|
|
193
|
+
if arg:
|
|
194
|
+
try:
|
|
195
|
+
apply_context(agent, parse_context(arg), hardware)
|
|
196
|
+
except ConfigError as e:
|
|
197
|
+
c.print(f"[red]{e}[/]")
|
|
198
|
+
return
|
|
199
|
+
options = context_options(agent, hardware)
|
|
200
|
+
table = Table(title=f"Context window for {s.model}", title_justify="left", header_style="bold")
|
|
201
|
+
table.add_column("#", justify="right", style="cyan")
|
|
202
|
+
table.add_column("Size", justify="right")
|
|
203
|
+
table.add_column("Memory · fit")
|
|
204
|
+
table.add_column("")
|
|
205
|
+
for i, option in enumerate(options, 1):
|
|
206
|
+
marks = []
|
|
207
|
+
if option["current"]:
|
|
208
|
+
marks.append("[bold]current[/]")
|
|
209
|
+
if option["recommended"]:
|
|
210
|
+
marks.append("[cyan]recommended[/]")
|
|
211
|
+
table.add_row(str(i), format_tokens(option["size"]), option["note"], ", ".join(marks))
|
|
212
|
+
c.print(table)
|
|
213
|
+
try:
|
|
214
|
+
answer = input(f" Choose a number, a size like 96k, or press Enter to keep {format_tokens(s.context)}: ")
|
|
215
|
+
except EOFError:
|
|
216
|
+
return
|
|
217
|
+
answer = answer.strip()
|
|
218
|
+
if not answer:
|
|
219
|
+
c.print(f"Keeping {format_tokens(s.context)}.")
|
|
220
|
+
return
|
|
221
|
+
if answer.isdigit() and 1 <= int(answer) <= len(options):
|
|
222
|
+
size = options[int(answer) - 1]["size"]
|
|
223
|
+
else:
|
|
224
|
+
try:
|
|
225
|
+
size = parse_context(answer)
|
|
226
|
+
except ConfigError as e:
|
|
227
|
+
c.print(f"[red]{e}[/]")
|
|
228
|
+
return
|
|
229
|
+
apply_context(agent, size, hardware)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def apply_context(agent: Agent, size: int, hardware: Hardware) -> None:
|
|
233
|
+
c, s = agent.console, agent.settings
|
|
234
|
+
if size == s.context:
|
|
235
|
+
c.print(f"The context window is already {format_tokens(size)}.")
|
|
236
|
+
return
|
|
237
|
+
if agent.has_conversation() and agent.ctx_used > AUTO_COMPACT_RATIO * size:
|
|
238
|
+
c.print(
|
|
239
|
+
f"This conversation uses {format_tokens(agent.ctx_used)} tokens, too much for {format_tokens(size)}; "
|
|
240
|
+
"summarizing it first."
|
|
241
|
+
)
|
|
242
|
+
try:
|
|
243
|
+
agent.compact()
|
|
244
|
+
except OllamaError as e:
|
|
245
|
+
c.print(f"[red]Couldn't summarize the conversation, so the context stays the same: {e}[/]")
|
|
246
|
+
return
|
|
247
|
+
c.print(agent.set_context(size))
|
|
248
|
+
spec = catalog.find(s.model)
|
|
249
|
+
if spec and spec.memory_gib(s.context) > hardware.budget_gib:
|
|
250
|
+
c.print(
|
|
251
|
+
f"[yellow]~{spec.memory_gib(s.context):.0f} GB needed but ~{hardware.budget_gib:.0f} GB available; "
|
|
252
|
+
"this may run out of memory, in which case lcode falls back to a smaller size.[/]"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
148
256
|
def web_status(agent: Agent) -> str:
|
|
149
257
|
if agent.settings.web == "off":
|
|
150
258
|
return "off"
|
|
@@ -196,28 +304,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
196
304
|
resume_session(agent, info)
|
|
197
305
|
elif cmd == "/compact":
|
|
198
306
|
agent.compact(arg)
|
|
199
|
-
elif cmd
|
|
200
|
-
|
|
201
|
-
f"Context: {format_tokens(agent.ctx_used)} of {format_tokens(s.context)} tokens "
|
|
202
|
-
f"({100 * agent.ctx_used / s.context:.1f}%) in {len(agent.messages)} messages; "
|
|
203
|
-
"auto-compacts at 85%."
|
|
204
|
-
)
|
|
205
|
-
elif cmd == "/ctx":
|
|
206
|
-
if not arg:
|
|
207
|
-
spec = catalog.find(s.model)
|
|
208
|
-
extra = f" · model maximum {format_tokens(spec.max_context)}" if spec else ""
|
|
209
|
-
c.print(f"Context window: {format_tokens(s.context)} tokens{extra}. Change it with /ctx 128k")
|
|
210
|
-
else:
|
|
211
|
-
try:
|
|
212
|
-
c.print(agent.set_context(parse_context(arg)))
|
|
213
|
-
except ConfigError as e:
|
|
214
|
-
c.print(f"[red]{e}[/]")
|
|
215
|
-
spec = catalog.find(s.model)
|
|
216
|
-
if spec and spec.memory_gib(s.context) > hardware.budget_gib:
|
|
217
|
-
c.print(
|
|
218
|
-
f"[yellow]~{spec.memory_gib(s.context):.0f} GB needed but ~{hardware.budget_gib:.0f} GB "
|
|
219
|
-
"available; this may run out of memory.[/]"
|
|
220
|
-
)
|
|
307
|
+
elif cmd in ("/context", "/ctx"):
|
|
308
|
+
context_command(agent, arg, hardware)
|
|
221
309
|
elif cmd == "/models":
|
|
222
310
|
print_models(agent.ollama, hardware, s.model)
|
|
223
311
|
elif cmd == "/model":
|
|
@@ -149,6 +149,18 @@ def test_malformed_tool_calls_give_up_eventually(make_agent):
|
|
|
149
149
|
agent.run_turn("write a.py")
|
|
150
150
|
|
|
151
151
|
|
|
152
|
+
def test_progress_line_keeps_counting_without_chunks(monkeypatch):
|
|
153
|
+
from lcode.agent import Progress
|
|
154
|
+
|
|
155
|
+
clock = [1000.0]
|
|
156
|
+
monkeypatch.setattr("lcode.agent.time.time", lambda: clock[0])
|
|
157
|
+
progress = Progress()
|
|
158
|
+
progress.update("Writing", "x" * 400)
|
|
159
|
+
assert progress.__rich__().plain.startswith("Writing… (100 tokens, 0s")
|
|
160
|
+
clock[0] += 125 # the model is silently writing a tool call
|
|
161
|
+
assert progress.__rich__().plain.startswith("Working… (100 tokens, 125s")
|
|
162
|
+
|
|
163
|
+
|
|
152
164
|
def test_mentions_attach_files(agent):
|
|
153
165
|
text = agent.expand_mentions("explain @src/pkg/math.py please, mail me@example.com")
|
|
154
166
|
assert '<file path="src/pkg/math.py">' in text
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from conftest import output, reply
|
|
4
|
+
from lcode import limits
|
|
5
|
+
from lcode.hardware import Hardware
|
|
6
|
+
from lcode.repl import context_options, handle_command
|
|
7
|
+
|
|
8
|
+
LAPTOP = Hardware("linux", "x", 31, "RTX 4080 Laptop", 12)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def answer(monkeypatch, text):
|
|
12
|
+
monkeypatch.setattr("builtins.input", lambda _: text)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_options_show_fit_current_and_recommended(agent):
|
|
16
|
+
options = {o["size"]: o for o in context_options(agent, LAPTOP)}
|
|
17
|
+
assert sorted(options) == [16384, 32768, 65536, 131072, 262144] # capped at the model's 256K
|
|
18
|
+
assert options[65536]["current"] and options[262144]["recommended"]
|
|
19
|
+
assert options[262144]["note"] == "~28 GB · GPU + RAM"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_options_warn_about_sizes_that_ran_out_of_memory(agent):
|
|
23
|
+
limits.record(agent.settings.model, 131072)
|
|
24
|
+
options = {o["size"]: o for o in context_options(agent, LAPTOP)}
|
|
25
|
+
assert "ran out of memory here before" in options[262144]["note"]
|
|
26
|
+
assert "ran out of memory" not in options[131072]["note"]
|
|
27
|
+
assert options[131072]["recommended"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_options_for_models_outside_the_catalog(make_agent):
|
|
31
|
+
agent = make_agent(model="custom:7b", context=8192)
|
|
32
|
+
options = context_options(agent, LAPTOP)
|
|
33
|
+
assert options[0]["size"] == 8192 and options[0]["current"]
|
|
34
|
+
assert options[0]["note"] == "no memory estimate for this model"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_pick_by_number(agent, monkeypatch):
|
|
38
|
+
answer(monkeypatch, "4") # 16K, 32K, 64K, 128K, ...
|
|
39
|
+
handle_command(agent, "/context", LAPTOP)
|
|
40
|
+
assert agent.settings.context == 131072
|
|
41
|
+
assert "Context window set to 128K" in output(agent)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_pick_by_size_and_keep(agent, monkeypatch):
|
|
45
|
+
answer(monkeypatch, "96k")
|
|
46
|
+
handle_command(agent, "/context", LAPTOP)
|
|
47
|
+
assert agent.settings.context == 98304
|
|
48
|
+
answer(monkeypatch, "")
|
|
49
|
+
handle_command(agent, "/ctx", LAPTOP)
|
|
50
|
+
assert agent.settings.context == 98304 and "Keeping 96K" in output(agent)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_bad_answer_changes_nothing(agent, monkeypatch):
|
|
54
|
+
answer(monkeypatch, "lots")
|
|
55
|
+
handle_command(agent, "/context", LAPTOP)
|
|
56
|
+
assert agent.settings.context == 65536 and "invalid context size" in output(agent)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_direct_size_skips_the_list(agent, monkeypatch):
|
|
60
|
+
monkeypatch.setattr("builtins.input", lambda _: pytest.fail("should not ask"))
|
|
61
|
+
handle_command(agent, "/context 32k", LAPTOP)
|
|
62
|
+
assert agent.settings.context == 32768
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_shrinking_below_the_conversation_summarizes_first(make_agent, monkeypatch):
|
|
66
|
+
agent = make_agent([reply("first answer"), reply("SUMMARY of the work")])
|
|
67
|
+
agent.run_turn("a long task")
|
|
68
|
+
agent.ctx_used = 60000 # more than 85% of 32K
|
|
69
|
+
monkeypatch.setattr("builtins.input", lambda _: pytest.fail("should not ask"))
|
|
70
|
+
handle_command(agent, "/context 32k", LAPTOP)
|
|
71
|
+
assert agent.settings.context == 32768
|
|
72
|
+
assert "SUMMARY of the work" in agent.messages[1]["content"]
|
|
73
|
+
assert "summarizing it first" in output(agent)
|
|
@@ -59,3 +59,12 @@ def test_always_and_modes(monkeypatch):
|
|
|
59
59
|
assert perms.request("edit", "edit", "Edit", "diff")[0]
|
|
60
60
|
perms.cycle()
|
|
61
61
|
assert perms.mode == "yolo"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.mark.parametrize(("kind", "key"), [("edit", "edit"), ("bash", "bash:rm"), ("web", "web:search")])
|
|
65
|
+
def test_every_kind_can_ask_and_remember(monkeypatch, kind, key):
|
|
66
|
+
perms = Permissions(Console(file=io.StringIO()), "ask")
|
|
67
|
+
monkeypatch.setattr("builtins.input", lambda _: "a")
|
|
68
|
+
assert perms.request(key, kind, "Title", "body") == (True, "")
|
|
69
|
+
monkeypatch.setattr("builtins.input", lambda _: pytest.fail("should not ask again"))
|
|
70
|
+
assert perms.request(key, kind, "Title", "body") == (True, "")
|
|
@@ -127,3 +127,14 @@ def test_truncate_keeps_head_and_tail():
|
|
|
127
127
|
text = "a" * 50 + "b" * 50
|
|
128
128
|
out = truncate(text, limit=30)
|
|
129
129
|
assert out.startswith("a" * 20) and out.endswith("b" * 10) and "truncated" in out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_edits_in_ask_mode(make_agent, repo: Path, monkeypatch):
|
|
133
|
+
"""Regression: file edits used to crash with IndexError whenever lcode asked for permission."""
|
|
134
|
+
agent = make_agent(mode="ask")
|
|
135
|
+
monkeypatch.setattr("builtins.input", lambda _: "y")
|
|
136
|
+
assert agent.tools.run("write_file", {"path": "NOTES.md", "content": "# Notes\n"}).startswith("Created")
|
|
137
|
+
agent.tools.run("read_file", {"path": "src/pkg/math.py"})
|
|
138
|
+
out = agent.tools.run("edit_file", {"path": "src/pkg/math.py", "old_string": "a - b", "new_string": "b - a"})
|
|
139
|
+
assert out.startswith("Edited src/pkg/math.py")
|
|
140
|
+
assert "return b - a" in (repo / "src/pkg/math.py").read_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
|