lcode-cli 0.2.0__py3-none-any.whl → 0.2.2__py3-none-any.whl

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/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """lcode — a local-first terminal coding agent powered by open-weight models via Ollama."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.2"
lcode/agent.py CHANGED
@@ -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
- status = self.console.status("[cyan]Thinking…[/]", spinner="dots")
305
- status.start()
306
- spinning, printed_thinking, t0 = True, False, time.time()
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 spinning
310
- if spinning:
338
+ nonlocal status
339
+ if status is not None:
311
340
  status.stop()
312
- spinning = False
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"])
lcode/permissions.py CHANGED
@@ -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 = {"edit": "file edits", "bash": key.split(":", 1)[1], "web": key.split(":", 1)[1]}[kind]
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:
lcode/repl.py CHANGED
@@ -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-window usage",
39
- "/ctx": "Show or change the context window, e.g. /ctx 128k",
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 == "/context":
200
- c.print(
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":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.2.0
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`, `/ctx 128k`, `/compact`, `/help` | switch model, resize context, summarize, list commands |
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
- On an RTX 4080 Laptop GPU (12 GB) the default model generates 50–60 tokens/s at 256K context, and
154
- qwen3.5-9b 64 tokens/s at 128K. See
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
- - More models in the catalog, with community test reports ([request one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
169
- - Measured Apple Silicon performance numbers
170
- - MCP (Model Context Protocol) tool servers
171
- - Web fetch and search tools
172
- - Packaging on PyPI and Homebrew
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
 
@@ -1,6 +1,6 @@
1
- lcode/__init__.py,sha256=_z-t2_EZYD-CKWphVqbpoHxiZaiXC0IANqgUDmhabQM,117
1
+ lcode/__init__.py,sha256=f69CJWRNbXrayd_uWS82hWDQJcvsIH6fRtjvpXj-I8s,117
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
- lcode/agent.py,sha256=HYbmZVkEGmWxEtnQRKexGXzCpcdFRK3-YkJJoH3PJ80,23499
3
+ lcode/agent.py,sha256=ppO9sMxymIVvFPeHS-5hqTyryQYBOK_0OAucaX6SeWU,24535
4
4
  lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
5
5
  lcode/cli.py,sha256=bvKv_5SGJgy8bkY-zFpXwyNACiGa6nu_n16ems_Vmq8,20103
6
6
  lcode/config.py,sha256=ZyddgIZ9TaHAcJSW_A8o7qFQbp1s2RHmsbFzOzEjV4o,5894
@@ -8,14 +8,14 @@ lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
8
8
  lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
9
9
  lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
10
10
  lcode/ollama.py,sha256=fELwqw7xqLH9TM7XvdI1pxqYZFeIRJBv-VSz9REdvwA,6516
11
- lcode/permissions.py,sha256=0HekNT4dupfSQTL58_8uNBrelldAvD2xB5kLehOy9QA,3928
11
+ lcode/permissions.py,sha256=wJKwhJQx44AzjOFZCWwM4M2oUlPFUNYI2cIVfoBGGgc,3898
12
12
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
13
- lcode/repl.py,sha256=xmeX00g17OyznlGT2H8l-qIT68fPfm1EqhX_3uBHvew,16380
13
+ lcode/repl.py,sha256=1InDmkMz286vuCOwRHNCTdBih_KekYkM4Ol52_g7C4s,20001
14
14
  lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
15
15
  lcode/tools.py,sha256=97wPiabQuztMr4qsMgf_beNAFfraxSlU2195QJffFkg,25835
16
16
  lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
17
- lcode_cli-0.2.0.dist-info/METADATA,sha256=g_YEh-2iFj2G9bSGAlOHIWrjoLNUnSxTpabdhg7abB0,8842
18
- lcode_cli-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
19
- lcode_cli-0.2.0.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
20
- lcode_cli-0.2.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
21
- lcode_cli-0.2.0.dist-info/RECORD,,
17
+ lcode_cli-0.2.2.dist-info/METADATA,sha256=XQ3SNVJtZ6MAxIM90xpJVSLQ17uCvulOJ00kyhSNbco,9232
18
+ lcode_cli-0.2.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
19
+ lcode_cli-0.2.2.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
20
+ lcode_cli-0.2.2.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
21
+ lcode_cli-0.2.2.dist-info/RECORD,,