lcode-cli 0.35.1__py3-none-any.whl → 0.36.0__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
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.35.1"
6
+ __version__ = "0.36.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
lcode/cli.py CHANGED
@@ -113,44 +113,96 @@ def choose_context(
113
113
  # ----------------------------------------------------------------------------- lcode models
114
114
 
115
115
 
116
- def print_served(client: backends.OpenAICompatible, current: str | None) -> None:
116
+ def print_served(
117
+ client: backends.OpenAICompatible, current: str | None, numbered: bool = False, out: Console | None = None
118
+ ) -> list[str]:
119
+ out = out or console
117
120
  try:
118
121
  served = client.chat_models()
119
122
  current = backends.pick_model(client, current) if current else None
120
123
  except OllamaError as e:
121
124
  if not isinstance(e, backends.BackendError) or "doesn't serve" not in str(e):
122
- console.print(f"[red]{e}[/]")
123
- return
124
- console.print(f"[bold]Models served by {escape(client.describe())}[/]")
125
- for name in served:
126
- console.print(f" {escape(name)}" + (" [bold]current[/]" if name == current else ""))
125
+ out.print(f"[red]{e}[/]")
126
+ return []
127
+ served = []
128
+ out.print(f"[bold]Models served by {escape(client.describe())}[/]")
129
+ for i, name in enumerate(served, 1):
130
+ number = f"[cyan]{i:>3}[/] " if numbered else " "
131
+ out.print(f"{number}{escape(name)}" + (" [bold]current[/]" if name == current else ""))
127
132
  if not served:
128
- console.print(" none listed")
129
- console.print(
130
- "[dim]Models are downloaded and loaded in the server itself. Choose one with [/]lcode --model <id>[dim] "
131
- "or [/]lcode config set model <id>[dim].[/]"
132
- )
133
+ out.print(" none listed")
134
+ if not numbered:
135
+ out.print(
136
+ "[dim]Models are downloaded and loaded in the server itself. Choose one with [/]lcode --model <id>[dim] "
137
+ "or [/]lcode config set model <id>[dim].[/]"
138
+ )
139
+ return served
133
140
 
134
141
 
135
- def print_models(ollama: Ollama | None, hw: Hardware, current: str | None = None) -> None:
142
+ def other_installed(ollama: Ollama, installed: set[str]) -> list[str]:
143
+ """Installed Ollama models outside the catalog that can chat and call tools (so lcode can use them), by name.
144
+ Embedding models are left out: Ollama may list `tools` for them too, but not `completion`."""
145
+ known = {name for spec in catalog.load() for name in (spec.tag, spec.local_name)}
146
+ usable = []
147
+ for name in sorted({n.removesuffix(":latest") for n in installed} - known):
148
+ try:
149
+ capabilities = ollama.show(name).get("capabilities") or []
150
+ if "completion" in capabilities and "tools" in capabilities:
151
+ usable.append(name)
152
+ except OllamaError:
153
+ continue
154
+ return usable
155
+
156
+
157
+ def model_choices(ollama: Ollama | None) -> list[str]:
158
+ """What `/models` numbers, in its order: the catalog's keys, then other installed models that call tools.
159
+ For an OpenAI-compatible server, the models it serves."""
136
160
  if ollama is not None and backends.is_openai(ollama):
137
- print_served(ollama, current) # type: ignore[arg-type]
138
- return
161
+ try:
162
+ return ollama.chat_models() # type: ignore[union-attr]
163
+ except OllamaError:
164
+ return []
165
+ try:
166
+ installed = ollama.installed_names() if ollama else set()
167
+ except OllamaError:
168
+ installed = set()
169
+ return [spec.key for spec in catalog.load()] + (other_installed(ollama, installed) if ollama else [])
170
+
171
+
172
+ def print_models(
173
+ ollama: Ollama | None,
174
+ hw: Hardware,
175
+ current: str | None = None,
176
+ numbered: bool = False,
177
+ out: Console | None = None,
178
+ ) -> list[str]:
179
+ """The models table; with `numbered`, a number on each row to pick it by. Returns the rows' names in order."""
180
+ out = out or console
181
+ if ollama is not None and backends.is_openai(ollama):
182
+ return print_served(ollama, current, numbered, out) # type: ignore[arg-type]
139
183
  try:
140
184
  installed = ollama.installed_names() if ollama else set()
141
185
  except OllamaError:
142
186
  installed = set()
143
187
  rec = catalog.recommend(hw)
144
- wide = console.width >= 130 # the descriptive columns don't fit an 80-column terminal
188
+ wide = out.width >= 130 # the descriptive columns don't fit an 80-column terminal
145
189
  columns = ["Key", "Model", "Type", "Size", "Max ctx", "Fits here", "Speed", "Status"]
146
190
  if not wide:
147
191
  columns = [c for c in columns if c not in ("Model", "Type")]
192
+ if numbered:
193
+ columns.insert(0, "#")
148
194
  table = Table(title=f"Models for this machine: {hw.describe()}", title_justify="left", header_style="bold")
149
195
  for col in columns:
150
196
  table.add_column(
151
- col, no_wrap=col in ("Key", "Size", "Max ctx", "Fits here"), min_width=11 if col == "Status" else None
197
+ col,
198
+ no_wrap=col in ("#", "Key", "Size", "Max ctx", "Fits here"),
199
+ min_width=11 if col == "Status" else None,
200
+ justify="right" if col == "#" else "left",
201
+ style="cyan" if col == "#" else None,
152
202
  )
203
+ choices: list[str] = []
153
204
  for spec in catalog.load():
205
+ choices.append(spec.key)
154
206
  ctx, speed = spec.fit(hw)
155
207
  marks = []
156
208
  if spec.local_name in installed or spec.tag in installed:
@@ -161,6 +213,7 @@ def print_models(ollama: Ollama | None, hw: Hardware, current: str | None = None
161
213
  marks.append("[bold]current[/]")
162
214
  marks.append("tested" if spec.tested else "[dim]untested[/]")
163
215
  row = {
216
+ "#": str(len(choices)),
164
217
  "Key": spec.key,
165
218
  "Model": spec.name,
166
219
  "Type": spec.params,
@@ -171,11 +224,18 @@ def print_models(ollama: Ollama | None, hw: Hardware, current: str | None = None
171
224
  "Status": ", ".join(marks) if wide else "\n".join(marks),
172
225
  }
173
226
  table.add_row(*(row[c] for c in columns))
174
- console.print(table)
175
- console.print(
176
- "[dim]Install one with [/]lcode setup <key>[dim]. 'Fits here' is the largest context that fits in memory; "
177
- "choose another with --context. Any other Ollama model with tool calling also works: lcode --model <tag>[/]"
178
- )
227
+ for name in other_installed(ollama, installed) if ollama else []:
228
+ choices.append(name)
229
+ marks = ["[green]installed[/]"] + (["[bold]current[/]"] if name == current else []) + ["[dim]not in catalog[/]"]
230
+ row = {"#": str(len(choices)), "Key": escape(name), "Status": ", ".join(marks) if wide else "\n".join(marks)}
231
+ table.add_row(*(row.get(c, "") for c in columns))
232
+ out.print(table)
233
+ if not numbered:
234
+ out.print(
235
+ "[dim]Install one with [/]lcode setup <key>[dim]. 'Fits here' is the largest context that fits in memory; "
236
+ "choose another with --context. Any other Ollama model with tool calling also works: lcode --model <tag>[/]"
237
+ )
238
+ return choices
179
239
 
180
240
 
181
241
  def cmd_models(args) -> None:
lcode/repl.py CHANGED
@@ -68,8 +68,8 @@ COMMANDS = {
68
68
  "/compact": "Summarize the conversation to free context (optional: what to focus on)",
69
69
  "/context": "Show context usage and change the window size: pick from a list, or /context 128k",
70
70
  "/ctx": "Shortcut for /context",
71
- "/model": "Show or switch model, e.g. /model qwen3.5-9b",
72
- "/models": "List models and how they fit this machine",
71
+ "/model": "Switch model: pick a number from the list, or /model 2, /model qwen3.5-9b",
72
+ "/models": "List models and how they fit this machine; type a number to switch",
73
73
  "/think": "Toggle model reasoning on/off",
74
74
  "/verbose": "Toggle showing the model's reasoning text",
75
75
  "/plan": "Plan before changing anything: /plan <request>, or /plan to show the approved plan",
@@ -243,14 +243,20 @@ def banner(agent: Agent) -> None:
243
243
  CONTEXT_CHOICES = (16384, 32768, 65536, 131072, 262144, 524288, 1048576)
244
244
 
245
245
 
246
+ def model_limit(agent: Agent) -> int | None:
247
+ """The largest context the model supports (what Ollama reports, else the catalog's), if known."""
248
+ spec = catalog.find(agent.settings.model)
249
+ try:
250
+ return agent.ollama.max_context(agent.settings.model) or (spec.max_context if spec else None)
251
+ except OllamaError:
252
+ return spec.max_context if spec else None
253
+
254
+
246
255
  def context_options(agent: Agent, hardware: Hardware) -> list[dict]:
247
256
  """The context sizes this model supports, with how each one fits this machine."""
248
257
  s = agent.settings
249
258
  spec = catalog.find(s.model)
250
- try:
251
- max_ctx = agent.ollama.max_context(s.model) or (spec.max_context if spec else None)
252
- except OllamaError:
253
- max_ctx = spec.max_context if spec else None
259
+ max_ctx = model_limit(agent)
254
260
  sizes = {c for c in CONTEXT_CHOICES if not max_ctx or c <= max_ctx} | {s.context}
255
261
  if max_ctx:
256
262
  sizes.add(max_ctx)
@@ -317,6 +323,15 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
317
323
  marks.append("[cyan]recommended[/]")
318
324
  table.add_row(str(i), format_tokens(option["size"]), option["note"], ", ".join(marks))
319
325
  c.print(table)
326
+ limit = model_limit(agent)
327
+ beyond = [size for size in CONTEXT_CHOICES if limit and size > limit]
328
+ if beyond:
329
+ sizes = " and ".join(format_tokens(size) for size in beyond[:2])
330
+ c.print(
331
+ f"[dim]Not offered: {sizes}{' and more' if len(beyond) > 2 else ''}. {s.model} was trained on up to "
332
+ f"{format_tokens(limit)} tokens, and Ollama caps the window there whatever the memory, so a larger "
333
+ "setting would only look larger.[/]"
334
+ )
320
335
  try:
321
336
  answer = input(f" Choose a number, a size like 96k, or press Enter to keep {format_tokens(s.context)}: ")
322
337
  except EOFError:
@@ -809,6 +824,63 @@ def restore_checkpoint(agent: Agent, target: Checkpoint, rewind: bool) -> None:
809
824
  agent.save()
810
825
 
811
826
 
827
+ def model_command(agent: Agent, arg: str, hardware: Hardware) -> None:
828
+ """/models and /model: the numbered list to pick from, or straight to a number or a name (/model 2)."""
829
+ from lcode.cli import model_choices, print_models
830
+
831
+ c, s = agent.console, agent.settings
832
+ if arg and not arg.isdigit():
833
+ switch_model(agent, arg, hardware)
834
+ return
835
+ if arg:
836
+ choices = model_choices(agent.ollama)
837
+ else:
838
+ choices = print_models(agent.ollama, hardware, s.model, numbered=True, out=c)
839
+ if not choices:
840
+ return
841
+ try:
842
+ answer = input(f" Switch to which model? (number or name, Enter to keep {s.model}): ").strip()
843
+ except EOFError:
844
+ return
845
+ if not answer:
846
+ c.print(f"Keeping {s.model}.")
847
+ return
848
+ arg = answer
849
+ if arg.isdigit():
850
+ if not 1 <= int(arg) <= len(choices):
851
+ c.print(f"[red]There's no model {arg} in the list: choose 1 to {len(choices)}.[/]")
852
+ return
853
+ arg = choices[int(arg) - 1]
854
+ switch_model(agent, arg, hardware)
855
+
856
+
857
+ def switch_model(agent: Agent, wanted: str, hardware: Hardware) -> None:
858
+ """Switch to a catalog key or an installed model, and size the context for it."""
859
+ from lcode.cli import NotInstalled, resolve_model
860
+
861
+ c, s = agent.console, agent.settings
862
+ try:
863
+ name, spec = resolve_model(agent.ollama, wanted)
864
+ except (NotInstalled, OllamaError) as e:
865
+ c.print(f"[red]{e}[/]")
866
+ return
867
+ if name == s.model:
868
+ c.print(f"Already using {name} (context {format_tokens(s.context)}).")
869
+ return
870
+ s.model = name
871
+ s.num_batch = spec.num_batch if spec and name == spec.local_name else None
872
+ if spec: # size the context for the new model: largest window that fits this machine
873
+ s.context = limits.cap(name, spec.fit(hardware)[0] or MIN_USEFUL_CONTEXT)
874
+ elif backends.is_openai(agent.ollama):
875
+ s.context = backends.context_for(agent.ollama, name, None)[0] # the server's window
876
+ agent._vision = False # the model that looks at images goes with the model: decide again
877
+ agent.messages[0]["content"] = agent.system_prompt()
878
+ c.print(f"[green]Switched to {name}[/] (context {format_tokens(s.context)}).")
879
+ warning = residency.dense_warning(agent.ollama, name, s.context, hardware)
880
+ if warning:
881
+ agent.say(warning, warning=True)
882
+
883
+
812
884
  def apply_context(agent: Agent, size: int, hardware: Hardware) -> None:
813
885
  c, s = agent.console, agent.settings
814
886
  if size == s.context:
@@ -956,8 +1028,6 @@ def session_start(agent: Agent) -> None:
956
1028
 
957
1029
  def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
958
1030
  """Run a slash command. Returns False when the REPL should exit."""
959
- from lcode.cli import NotInstalled, print_models, resolve_model
960
-
961
1031
  cmd, _, arg = line.partition(" ")
962
1032
  arg = arg.strip()
963
1033
  c, s = agent.console, agent.settings
@@ -1006,29 +1076,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
1006
1076
  agent.compact(arg)
1007
1077
  elif cmd in ("/context", "/ctx"):
1008
1078
  context_command(agent, arg, hardware)
1009
- elif cmd == "/models":
1010
- print_models(agent.ollama, hardware, s.model)
1011
- elif cmd == "/model":
1012
- if not arg:
1013
- c.print(f"Model: {s.model}. Switch with /model <name>; list with /models.")
1014
- else:
1015
- try:
1016
- name, spec = resolve_model(agent.ollama, arg)
1017
- except (NotInstalled, OllamaError) as e:
1018
- c.print(f"[red]{e}[/]")
1019
- return True
1020
- s.model = name
1021
- s.num_batch = spec.num_batch if spec and name == spec.local_name else None
1022
- if spec: # size the context for the new model: largest window that fits this machine
1023
- s.context = limits.cap(name, spec.fit(hardware)[0] or MIN_USEFUL_CONTEXT)
1024
- elif backends.is_openai(agent.ollama):
1025
- s.context = backends.context_for(agent.ollama, name, None)[0] # the server's window
1026
- agent._vision = False # the model that looks at images goes with the model: decide again
1027
- agent.messages[0]["content"] = agent.system_prompt()
1028
- c.print(f"[green]Switched to {name}[/] (context {format_tokens(s.context)}).")
1029
- warning = residency.dense_warning(agent.ollama, name, s.context, hardware)
1030
- if warning:
1031
- agent.say(warning, warning=True)
1079
+ elif cmd in ("/models", "/model"):
1080
+ model_command(agent, arg, hardware)
1032
1081
  elif cmd == "/think":
1033
1082
  s.think = (arg == "on") if arg in ("on", "off") else not s.think
1034
1083
  c.print(f"Reasoning {'on' if s.think else 'off'}.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.35.1
3
+ Version: 0.36.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/
@@ -1,4 +1,4 @@
1
- lcode/__init__.py,sha256=zUaeUug04u05tZncoC6Kos2DYIKI8_-kGSal29vOt5M,473
1
+ lcode/__init__.py,sha256=yGzk6CSXaFMumjYybvLVXP6ECcm-RN4YJ77pJlK1FWw,473
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
3
  lcode/acp.py,sha256=d97VV3sbqXdWKhPQQNbYRaDTm8WrUk0mGbDLiOyX7jE,29058
4
4
  lcode/action.py,sha256=wuT5F9EJCdEligPeytzmEi8FpahWBhXer528tpuTj_k,16711
@@ -11,7 +11,7 @@ lcode/bench_runtime.py,sha256=EjzNgIvUkdbT8cbSy1TpmlyeIk6e-ipjlDFpM7Dnqx0,6835
11
11
  lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
12
12
  lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
13
13
  lcode/checks.py,sha256=iPaNaGOGo7zHc_mue41ELEHSNq0VIH_xVYL_bHkYLXU,9373
14
- lcode/cli.py,sha256=nPvGnL0gZiUkbTniCoTAAyItiPEXZ-f6rHLYYUOKSiU,42151
14
+ lcode/cli.py,sha256=vtrGb1A_Jnxulu-L0nac01ARdQ8dyxZI4fapb-0G2N4,44703
15
15
  lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
16
16
  lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
17
17
  lcode/config.py,sha256=hAjuHg7plRxwFgdD4gOGnDH8-FwyT7NRV0YI5grd0qE,12604
@@ -34,7 +34,7 @@ lcode/permissions.py,sha256=q7CjDsc_Xrblw7t3_BbxA5LUjxFSswGs1RiJmtMi0po,12288
34
34
  lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
35
35
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
36
36
  lcode/repeats.py,sha256=vpXR5pzKX9GsTzQONGS30V-rEU3gunaj_hBzJxg43Q4,5542
37
- lcode/repl.py,sha256=adq99lxLLr43AQMEHT3qNLNXho4kiQIJUzJTWO9-pmY,54646
37
+ lcode/repl.py,sha256=2i_EDAsXxQ9OxXyWiuPGtIY4pLO3lQW4_SulITcThkM,56548
38
38
  lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
39
39
  lcode/residency.py,sha256=bDB2J0SZULDOz6wYuMCnkjacYk7pWi5f75iY83ycAXQ,6912
40
40
  lcode/review.py,sha256=0QBIS-R1B2b3zqx5TcC1qZptpKBbrDhYvMKSNCaDAUo,4686
@@ -65,8 +65,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
65
65
  lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
66
66
  lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
67
67
  lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
68
- lcode_cli-0.35.1.dist-info/METADATA,sha256=QUw9A6Zoi4mnZTYIimLNmID9EjahV_ZlF7ZJmBm5NUw,13141
69
- lcode_cli-0.35.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
70
- lcode_cli-0.35.1.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
71
- lcode_cli-0.35.1.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
72
- lcode_cli-0.35.1.dist-info/RECORD,,
68
+ lcode_cli-0.36.0.dist-info/METADATA,sha256=kp6gDSBL_DtXIWYn7FRO6CpzOf8t-dQPMOwpCQ_4trU,13141
69
+ lcode_cli-0.36.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
70
+ lcode_cli-0.36.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
71
+ lcode_cli-0.36.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
72
+ lcode_cli-0.36.0.dist-info/RECORD,,