lcode-cli 0.41.0__py3-none-any.whl → 0.42.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.41.0"
6
+ __version__ = "0.42.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
lcode/agent.py CHANGED
@@ -309,6 +309,7 @@ class Settings:
309
309
  comfyui_path: str | None = None # a ComfyUI install lcode doesn't find by itself (lcode.comfy)
310
310
  media_dir: str = ".lcode/media" # where image and video mode save their files (lcode.media)
311
311
  image_model: str = "flux2-klein-4b" # the media catalog's model image mode uses (lcode.media_catalog)
312
+ preview: str = "auto" # images in the terminal: auto, kitty, iterm, blocks or off (lcode.preview)
312
313
  num_batch: int | None = None
313
314
  keep_alive: str = "30m"
314
315
  think: bool = True
@@ -375,6 +376,11 @@ class Agent:
375
376
  self.interactive = True # False for `lcode -p`: no end-of-session questions
376
377
  self.comfy_process = None # the ComfyUI lcode started for image and video mode, stopped when it exits
377
378
  self.no_coding_model = "" # why there's no coding model, when lcode started in image or video mode without
379
+ from lcode.imageopts import Options as ImageOptions
380
+
381
+ self.image_options = ImageOptions() # image mode's defaults for this session (/options)
382
+ self.media_results: list[dict] = [] # the session's images, numbered (/gallery, /vary, /open)
383
+ self.last_request: tuple[str, ImageOptions] | None = None # for /again
378
384
  self.reflected = 0 # messages before this index were already checked for notes to remember
379
385
  self._memory: memory_notes.Memory | None = None
380
386
  self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
@@ -679,8 +685,10 @@ class Agent:
679
685
  return any(m.get("role") == "user" for m in self.messages)
680
686
 
681
687
  def save(self) -> None:
682
- if not (self.has_conversation() or self.session_name):
688
+ if not (self.has_conversation() or self.session_name or self.media_results):
683
689
  return # nothing worth resuming
690
+ if not self.session_title and not self.has_conversation() and self.media_results:
691
+ self.session_title = " ".join(self.media_results[0]["prompt"].split())[:60] # an image session
684
692
  self.session_title = self.session_title or sessions.title_from(self.messages)
685
693
  f = self.session_file()
686
694
  f.parent.mkdir(parents=True, exist_ok=True)
@@ -689,6 +697,7 @@ class Agent:
689
697
  "model": self.settings.model,
690
698
  "mode": self.settings.mode,
691
699
  "image_model": self.settings.image_model,
700
+ "media": {"results": self.media_results, "options": self.image_options.to_json()},
692
701
  "name": self.session_name,
693
702
  "title": self.session_title,
694
703
  "messages": self.messages,
@@ -739,6 +748,9 @@ class Agent:
739
748
  self.settings.mode = data["mode"] # a session belongs to the mode it was made in
740
749
  if isinstance(data.get("image_model"), str):
741
750
  self.settings.image_model = data["image_model"]
751
+ media = data.get("media") or {}
752
+ self.media_results = [r for r in media.get("results") or [] if isinstance(r, dict) and "path" in r]
753
+ self.image_options = type(self.image_options).from_json(media.get("options") or {})
742
754
  self.session_title = data.get("title") or sessions.title_from(self.messages)
743
755
  self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
744
756
  self.tools.todos = list(data.get("todos") or [])
lcode/api.py CHANGED
@@ -201,6 +201,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
201
201
  comfyui_path=cfg["comfyui_path"],
202
202
  media_dir=cfg["media_dir"],
203
203
  image_model=cfg["image_model"],
204
+ preview=cfg["preview"],
204
205
  web=switch(options.web, cfg["web"], "on"),
205
206
  search_backend=cfg["search_backend"],
206
207
  searxng_url=cfg["searxng_url"],
lcode/cli.py CHANGED
@@ -812,7 +812,7 @@ def media_request(agent, prompt: str, out: Console, quiet: bool) -> None:
812
812
  api.close_agent(agent)
813
813
  sys.exit(2)
814
814
  try:
815
- made = media.generate_image(agent, prompt)
815
+ made = media.request(agent, prompt)
816
816
  except (media.MediaError, KeyboardInterrupt) as e:
817
817
  message = str(e) or "Cancelled."
818
818
  if quiet:
@@ -826,12 +826,13 @@ def media_request(agent, prompt: str, out: Console, quiet: bool) -> None:
826
826
  "type": "result",
827
827
  "status": "success",
828
828
  "mode": "image",
829
- "files": [str(path) for path in made.files],
830
- "model": made.model,
831
- "seed": made.seed,
832
- "width": made.width,
833
- "height": made.height,
834
- "seconds": round(made.seconds, 1),
829
+ "files": [str(path) for one in made for path in one.files],
830
+ "model": made[0].model,
831
+ "seed": made[0].seed,
832
+ "seeds": [one.seed for one in made],
833
+ "width": made[0].width,
834
+ "height": made[0].height,
835
+ "seconds": round(sum(one.seconds for one in made), 1),
835
836
  }
836
837
  print(json.dumps(result, ensure_ascii=False), flush=True)
837
838
  api.close_agent(agent)
lcode/comfy.py CHANGED
@@ -560,13 +560,15 @@ def describe_history_error(entry: dict) -> str:
560
560
 
561
561
 
562
562
  def save(server: Server, outputs: dict, folder: Path, stem: str, meta: dict) -> list[Path]:
563
- """Fetch the job's files into `folder`: stem-1.png, … with stem-1.json next to each."""
563
+ """Fetch the job's files into `folder`: stem.png (stem-2.png if that's taken), or stem-1.png, … for several,
564
+ each with its settings in a .json next to it."""
564
565
  folder.mkdir(parents=True, exist_ok=True)
565
566
  items = [item for node in outputs.values() for kind in OUTPUT_KINDS for item in node.get(kind, [])]
567
+ items = [it for it in items if it.get("type", "output") == "output"]
566
568
  saved = []
567
- for i, item in enumerate((it for it in items if it.get("type", "output") == "output"), 1):
569
+ for i, item in enumerate(items, 1):
568
570
  ext = Path(item["filename"]).suffix or ".png"
569
- path = unique(folder / f"{stem}-{i}{ext}")
571
+ path = unique(folder / (f"{stem}{ext}" if len(items) == 1 else f"{stem}-{i}{ext}"))
570
572
  data = server.fetch(item)
571
573
  if ext.lower() == ".png":
572
574
  data = with_text(data, "lcode", json.dumps(meta, ensure_ascii=False))
lcode/config.py CHANGED
@@ -46,6 +46,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
46
46
  "comfyui_path": (None, str, "your ComfyUI folder, if lcode doesn't find it (lcode starts it when needed)"),
47
47
  "media_dir": (".lcode/media", str, "where images and videos go: a folder in the project, or an absolute path"),
48
48
  "image_model": ("flux2-klein-4b", str, "the model image mode uses: a key from `lcode media`"),
49
+ "preview": ("auto", str, "images in the terminal: auto | kitty | iterm | blocks (colour text) | off"),
49
50
  "think": (True, bool, "let the model reason before answering (slower, better)"),
50
51
  "web": ("on", str, "web search and page fetching for the model: on | ask | off"),
51
52
  "search_backend": ("auto", str, "auto | ollama | brave | tavily | searxng (keys come from environment variables)"),
@@ -211,6 +212,11 @@ def coerce(key: str, value: object) -> object:
211
212
  raise ConfigError(f"permission_mode must be one of {', '.join(PERMISSION_MODES)}")
212
213
  if key == "mode" and value not in WORK_MODES:
213
214
  raise ConfigError(f"mode must be one of {', '.join(WORK_MODES)}")
215
+ if key == "preview":
216
+ from lcode.preview import PROTOCOLS
217
+
218
+ if value not in PROTOCOLS:
219
+ raise ConfigError(f"preview must be one of {', '.join(PROTOCOLS)}")
214
220
  if key == "image_model":
215
221
  from lcode import media_catalog
216
222
 
lcode/imageopts.py ADDED
@@ -0,0 +1,207 @@
1
+ """Image options: how many, what shape, which seed, how good, what to avoid, which style.
2
+
3
+ They're typed in the request (`a red fox --n 4 --ar 16:9 --seed 42 --quality best --style photo --no "people"`), or
4
+ set for the session with `/options n=4 ar=16:9`. Flags in a request win over the session's options for that request.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import re
11
+ import shlex
12
+ from dataclasses import asdict, dataclass, replace
13
+
14
+ QUALITIES = ("fast", "balanced", "best")
15
+ MAX_COUNT = 8
16
+ SIZES = { # names for aspect ratios
17
+ "square": "1:1",
18
+ "portrait": "3:4",
19
+ "landscape": "4:3",
20
+ "wide": "16:9",
21
+ "tall": "9:16",
22
+ "photo": "3:2",
23
+ "cinema": "21:9",
24
+ }
25
+ STYLES = {
26
+ "photo": "professional photograph, natural light, sharp focus, 35mm lens",
27
+ "cinematic": "cinematic film still, dramatic lighting, shallow depth of field, color graded",
28
+ "anime": "anime illustration, clean line art, vibrant cel shading",
29
+ "watercolor": "watercolor painting, soft washes, visible paper texture",
30
+ "oil": "oil painting, visible brush strokes, rich colors",
31
+ "pixel": "pixel art, 16-bit, crisp pixels, limited palette",
32
+ "3d": "3D render, soft global illumination, studio lighting",
33
+ "sketch": "pencil sketch, graphite, cross-hatching, on paper",
34
+ "comic": "comic book art, bold ink outlines, halftone shading",
35
+ "flat": "flat vector illustration, minimal, bold shapes",
36
+ }
37
+ ALIASES = {
38
+ "n": "n",
39
+ "count": "n",
40
+ "ar": "size",
41
+ "aspect": "size",
42
+ "size": "size",
43
+ "seed": "seed",
44
+ "quality": "quality",
45
+ "q": "quality",
46
+ "no": "negative",
47
+ "negative": "negative",
48
+ "style": "style",
49
+ }
50
+ FLAG = re.compile(r"(?:^|\s)--?(" + "|".join(sorted(ALIASES, key=len, reverse=True)) + r")\b(?=[\s=]|$)")
51
+ DIMENSIONS = re.compile(r"^(\d{2,4})\s*[x×]\s*(\d{2,4})$")
52
+ RATIO = re.compile(r"^(\d{1,2})\s*:\s*(\d{1,2})$")
53
+
54
+
55
+ class OptionError(ValueError):
56
+ pass
57
+
58
+
59
+ @dataclass
60
+ class Options:
61
+ n: int = 1
62
+ size: str = "1:1" # an aspect ratio (16:9), a name (wide) or pixels (1344x768)
63
+ seed: int | None = None # None: a new random seed for each image
64
+ quality: str = "balanced"
65
+ negative: str = ""
66
+ style: str = ""
67
+
68
+ def describe(self) -> str:
69
+ seed = "random" if self.seed is None else str(self.seed)
70
+ parts = [f"n={self.n}", f"size={self.size}", f"seed={seed}", f"quality={self.quality}"]
71
+ if self.style:
72
+ parts.append(f"style={self.style}")
73
+ if self.negative:
74
+ parts.append(f"no={self.negative!r}")
75
+ return " ".join(parts)
76
+
77
+ def to_json(self) -> dict:
78
+ return asdict(self)
79
+
80
+ @classmethod
81
+ def from_json(cls, data: dict) -> Options:
82
+ known = {k: v for k, v in (data or {}).items() if k in cls.__dataclass_fields__}
83
+ try:
84
+ return set_values(cls(), known)
85
+ except OptionError:
86
+ return cls()
87
+
88
+
89
+ def set_values(options: Options, values: dict[str, object]) -> Options:
90
+ """A copy of `options` with `values` (by name or alias) applied. Raises OptionError saying what's wrong."""
91
+ out = replace(options)
92
+ for raw_key, raw in values.items():
93
+ key = ALIASES.get(str(raw_key).lower().lstrip("-"))
94
+ if key is None:
95
+ raise OptionError(f"unknown option {raw_key!r}: use n, ar (or size), seed, quality, style or no")
96
+ value = str(raw).strip() if raw is not None else ""
97
+ if key == "n":
98
+ if not value.isdigit() or not 1 <= int(value) <= MAX_COUNT:
99
+ raise OptionError(f"n is how many images: 1 to {MAX_COUNT}")
100
+ out.n = int(value)
101
+ elif key == "size":
102
+ out.size = check_size(value)
103
+ elif key == "seed":
104
+ if value.lower() in ("random", "none", ""):
105
+ out.seed = None
106
+ elif value.isdigit() and int(value) < 2**63:
107
+ out.seed = int(value)
108
+ else:
109
+ raise OptionError("seed is a whole number, or random")
110
+ elif key == "quality":
111
+ if value.lower() not in QUALITIES:
112
+ raise OptionError(f"quality is {', '.join(QUALITIES)}")
113
+ out.quality = value.lower()
114
+ elif key == "style":
115
+ if value.lower() in ("none", "off", ""):
116
+ out.style = ""
117
+ elif value.lower() in STYLES:
118
+ out.style = value.lower()
119
+ else:
120
+ raise OptionError(f"style is one of {', '.join(STYLES)} (or none)")
121
+ elif key == "negative":
122
+ out.negative = "" if value.lower() in ("none", "off") else value
123
+ return out
124
+
125
+
126
+ def check_size(value: str) -> str:
127
+ v = value.lower().replace(" ", "")
128
+ if v in SIZES:
129
+ return v
130
+ if RATIO.match(v):
131
+ w, h = (int(x) for x in RATIO.match(v).groups())
132
+ if w and h and 1 / 4 <= w / h <= 4:
133
+ return f"{w}:{h}"
134
+ m = DIMENSIONS.match(v)
135
+ if m:
136
+ w, h = (int(x) for x in m.groups())
137
+ if 256 <= w <= 2048 and 256 <= h <= 2048:
138
+ return f"{w}x{h}"
139
+ raise OptionError("a size in pixels is 256 to 2048 on each side, e.g. 1344x768")
140
+ raise OptionError(f"size is a ratio (16:9), pixels (1344x768) or one of {', '.join(SIZES)}")
141
+
142
+
143
+ def parse(text: str, defaults: Options) -> tuple[str, Options]:
144
+ """The prompt without its flags, and the options for this request. Raises OptionError."""
145
+ matches = list(FLAG.finditer(text))
146
+ if not matches:
147
+ return text.strip(), defaults
148
+ prompt = text[: matches[0].start()]
149
+ values: dict[str, str] = {}
150
+ for i, m in enumerate(matches):
151
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
152
+ rest = text[m.end() : end].strip().lstrip("=").strip()
153
+ try:
154
+ words = shlex.split(rest)
155
+ except ValueError:
156
+ words = rest.split()
157
+ key = ALIASES[m.group(1).lower()]
158
+ if key == "negative":
159
+ values[key] = " ".join(words)
160
+ else:
161
+ if not words:
162
+ raise OptionError(f"--{m.group(1)} needs a value")
163
+ values[key] = words[0]
164
+ if len(words) > 1: # words after a flag's value belong to the prompt
165
+ prompt += " " + " ".join(words[1:])
166
+ return prompt.strip(), set_values(defaults, values)
167
+
168
+
169
+ def parse_assignments(text: str) -> dict[str, str]:
170
+ """`n=4 ar=16:9 no="blurry, text"` -> {"n": "4", "ar": "16:9", "no": "blurry, text"}."""
171
+ try:
172
+ words = shlex.split(text)
173
+ except ValueError as e:
174
+ raise OptionError(f"can't read {text!r}: {e}") from e
175
+ out = {}
176
+ for word in words:
177
+ key, sep, value = word.partition("=")
178
+ if not sep:
179
+ raise OptionError(f"write options as name=value, e.g. n=4 ar=16:9 (not {word!r})")
180
+ out[key] = value
181
+ return out
182
+
183
+
184
+ def dimensions(size: str, megapixels: float, multiple: int = 16) -> tuple[int, int]:
185
+ """Width and height in pixels: from a ratio and the quality's megapixels, or exact pixels as given."""
186
+ m = DIMENSIONS.match(size)
187
+ if m:
188
+ w, h = (int(x) for x in m.groups())
189
+ return round_to(w, multiple), round_to(h, multiple)
190
+ ratio = SIZES.get(size, size)
191
+ w, h = (int(x) for x in ratio.split(":"))
192
+ area = megapixels * 1024 * 1024
193
+ width = math.sqrt(area * w / h)
194
+ return round_to(width, multiple), round_to(width * h / w, multiple)
195
+
196
+
197
+ def round_to(value: float, multiple: int) -> int:
198
+ return max(multiple, round(value / multiple) * multiple)
199
+
200
+
201
+ def replace_seed(options: Options, n: int | None = None) -> Options:
202
+ """The options with a random seed again (for /again and /vary), and optionally another count."""
203
+ return replace(options, seed=None, n=n or options.n)
204
+
205
+
206
+ def styled(prompt: str, style: str) -> str:
207
+ return f"{prompt}, {STYLES[style]}" if style else prompt
lcode/media.py CHANGED
@@ -6,16 +6,21 @@ sizes, variations and a gallery come with #151, video with #153.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import os
9
10
  import random
11
+ import shutil
12
+ import subprocess
13
+ import sys
10
14
  import time
11
15
  from collections.abc import Callable
12
- from dataclasses import dataclass
16
+ from dataclasses import dataclass, field, replace
13
17
  from pathlib import Path
14
18
  from typing import TYPE_CHECKING
15
19
 
20
+ import requests
16
21
  from rich.text import Text
17
22
 
18
- from lcode import comfy
23
+ from lcode import comfy, imageopts, preview
19
24
  from lcode import media_catalog as catalog
20
25
 
21
26
  if TYPE_CHECKING:
@@ -52,6 +57,9 @@ class Made:
52
57
  width: int
53
58
  height: int
54
59
  seconds: float
60
+ prompt: str = ""
61
+ numbers: list[int] = field(default_factory=list) # in the session's gallery
62
+ thumbnail: bytes = b"" # a small PNG, for the half-block preview
55
63
 
56
64
 
57
65
  # ----------------------------------------------------------------------------- ComfyUI, started and stopped
@@ -217,13 +225,79 @@ def ensure_installed(
217
225
  return files
218
226
 
219
227
 
228
+ THUMB_NODES = ("9001", "9002") # the thumbnail lcode adds to a workflow, for the half-block preview
229
+
230
+
231
+ def add_thumbnail(graph: dict, width: int, height: int) -> None:
232
+ """Have ComfyUI also make a small copy of the image (not saved, a preview), for the half-block preview."""
233
+ save = next((nid for nid, node in graph.items() if node.get("class_type") == "SaveImage"), None)
234
+ if save is None:
235
+ return
236
+ tw, th = preview.thumbnail_size(width, height)
237
+ scale, show = THUMB_NODES
238
+ graph[scale] = {
239
+ "class_type": "ImageScale",
240
+ "inputs": {"image": graph[save]["inputs"]["images"], "upscale_method": "area", "width": tw, "height": th,
241
+ "crop": "disabled"},
242
+ } # fmt: skip
243
+ graph[show] = {"class_type": "PreviewImage", "inputs": {"images": [scale, 0]}}
244
+
245
+
246
+ def thumbnail(server: comfy.Server, outputs: dict) -> bytes:
247
+ for item in (outputs.get(THUMB_NODES[1]) or {}).get("images", []):
248
+ try:
249
+ return server.fetch(item)
250
+ except (comfy.ComfyError, OSError, requests.RequestException):
251
+ return b""
252
+ return b""
253
+
254
+
255
+ def request(
256
+ agent: Agent, text: str, cancelled: Callable[[], bool] = lambda: False, ask: Callable[[str], bool] | None = None
257
+ ) -> list[Made]:
258
+ """A typed request: the prompt and its flags (--n 4 --ar 16:9 …, over the session's /options), then the images."""
259
+ try:
260
+ prompt, options = imageopts.parse(text, agent.image_options)
261
+ except imageopts.OptionError as e:
262
+ raise MediaError(str(e)) from e
263
+ return make(agent, prompt, options, cancelled, ask)
264
+
265
+
266
+ def make(
267
+ agent: Agent,
268
+ prompt: str,
269
+ options: imageopts.Options,
270
+ cancelled: Callable[[], bool] = lambda: False,
271
+ ask: Callable[[str], bool] | None = None,
272
+ ) -> list[Made]:
273
+ """`options.n` images of the prompt; a fixed seed counts up from image to image."""
274
+ if not prompt.strip():
275
+ raise MediaError("Describe the image you want.")
276
+ model = current(agent)
277
+ if options.negative and not model.negative:
278
+ agent.say(f"{model.name} doesn't use a negative prompt: describe what you want instead (--no is ignored).")
279
+ agent.last_request = (prompt, options)
280
+ made = []
281
+ for i in range(options.n):
282
+ seed = None if options.seed is None else options.seed + i
283
+ label = f"{i + 1} of {options.n}" if options.n > 1 else ""
284
+ made.append(generate_image(agent, prompt, cancelled, ask, replace(options, seed=seed, n=1), label))
285
+ return made
286
+
287
+
220
288
  def generate_image(
221
- agent: Agent, prompt: str, cancelled: Callable[[], bool] = lambda: False, ask: Callable[[str], bool] | None = None
289
+ agent: Agent,
290
+ prompt: str,
291
+ cancelled: Callable[[], bool] = lambda: False,
292
+ ask: Callable[[str], bool] | None = None,
293
+ options: imageopts.Options | None = None,
294
+ label: str = "",
222
295
  ) -> Made:
223
- """One image from the prompt, saved in the media folder. Raises MediaError with what to do."""
296
+ """One image from the prompt, saved in the media folder and numbered. Raises MediaError with what to do."""
224
297
  prompt = prompt.strip()
225
298
  if not prompt:
226
299
  raise MediaError("Describe the image you want.")
300
+ options = options or imageopts.Options()
227
301
  server = engine(agent, ask, cancelled)
228
302
  for path in comfy.collect(server):
229
303
  agent.say(f"Saved {shown(agent, path)}: it finished while lcode wasn't watching.")
@@ -231,25 +305,40 @@ def generate_image(
231
305
  files = ensure_installed(agent, model, server, ask)
232
306
  workflow = comfy.load_workflow(model.workflow)
233
307
  free_gpu(agent)
234
- seed = random.randrange(2**32)
235
- values = {**workflow.defaults, **files, "prompt": prompt, "seed": seed}
308
+ seed = options.seed if options.seed is not None else random.randrange(2**32)
309
+ steps, megapixels = model.preset(options.quality)
310
+ width, height = imageopts.dimensions(options.size, megapixels)
311
+ text = imageopts.styled(prompt, options.style)
312
+ values = {**workflow.defaults, **files, "prompt": text, "seed": seed, "width": width, "height": height}
313
+ if steps:
314
+ values["steps"] = steps
315
+ if model.negative:
316
+ values["negative"] = options.negative
236
317
  graph = comfy.fill(workflow.graph, values)
318
+ add_thumbnail(graph, width, height)
237
319
  meta = {
238
320
  "prompt": prompt,
239
321
  "model": model.name,
240
322
  "model_key": model.key,
241
323
  "workflow": workflow.key,
242
324
  "seed": seed,
243
- "width": values["width"],
244
- "height": values["height"],
325
+ "width": width,
326
+ "height": height,
245
327
  "steps": values["steps"],
328
+ "quality": options.quality,
329
+ "style": options.style,
330
+ "negative": options.negative if model.negative else "",
331
+ "full_prompt": text,
246
332
  "files": files,
247
333
  "created": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
248
334
  }
249
335
  out = folder(agent)
250
336
  stem = f"{time.strftime('%Y%m%d-%H%M%S')}-{comfy.slug(prompt)}"
251
- line = Line(model.name, "Esc or Ctrl+C cancels" if agent.interactive else "")
337
+ line = Line(
338
+ f"{model.name} ({label})" if label else model.name, "Esc or Ctrl+C cancels" if agent.interactive else ""
339
+ )
252
340
  job = {"id": ""}
341
+ labels = {**workflow.labels, THUMB_NODES[0]: "making the preview", THUMB_NODES[1]: "making the preview"}
253
342
 
254
343
  def on_event(event: comfy.Event) -> None:
255
344
  if event.kind == "queued":
@@ -258,9 +347,9 @@ def generate_image(
258
347
  elif event.kind == "waiting":
259
348
  line.doing = f"waiting: {event.value} job(s) ahead in ComfyUI's queue"
260
349
  elif event.kind == "node":
261
- line.doing, line.step = workflow.labels.get(event.node, "working"), None
350
+ line.doing, line.step = labels.get(event.node, "working"), None
262
351
  elif event.kind == "progress" and event.total:
263
- line.doing = workflow.labels.get(event.node, line.doing)
352
+ line.doing = labels.get(event.node, line.doing)
264
353
  line.progress(event.value, event.total)
265
354
 
266
355
  try:
@@ -283,11 +372,102 @@ def generate_image(
283
372
  comfy.forget(prompt_id)
284
373
  if not saved:
285
374
  raise MediaError("ComfyUI finished but returned no image.")
286
- made = Made(saved, model.name, seed, values["width"], values["height"], time.monotonic() - line.started)
287
- for path in saved:
375
+ made = Made(saved, model.name, seed, width, height, time.monotonic() - line.started, prompt)
376
+ made.thumbnail = thumbnail(server, outputs)
377
+ made.numbers = record(agent, made, meta, options)
378
+ show(agent, made)
379
+ return made
380
+
381
+
382
+ # ----------------------------------------------------------------------------- results: numbered, shown, opened
383
+
384
+
385
+ def record(agent: Agent, made: Made, meta: dict, options: imageopts.Options) -> list[int]:
386
+ """Number the images in the session's gallery, with what made them (for /gallery, /vary, /open)."""
387
+ numbers = []
388
+ for path in made.files:
389
+ number = len(agent.media_results) + 1
390
+ agent.media_results.append(
391
+ {
392
+ "n": number,
393
+ "path": str(path),
394
+ "prompt": made.prompt,
395
+ "model": made.model,
396
+ "seed": made.seed,
397
+ "width": made.width,
398
+ "height": made.height,
399
+ "steps": meta["steps"],
400
+ "seconds": round(made.seconds, 1),
401
+ "options": options.to_json(),
402
+ }
403
+ )
404
+ numbers.append(number)
405
+ return numbers
406
+
407
+
408
+ def show(agent: Agent, made: Made) -> None:
409
+ for path, number in zip(made.files, made.numbers or [0] * len(made.files), strict=False):
288
410
  agent.console.print(
289
411
  Text("◆ ", style="green")
412
+ + Text(f"#{number} " if number else "", style="bold green")
290
413
  + Text(shown(agent, path), style="bold")
291
- + Text(f" {made.width}×{made.height} · {made.model} · seed {seed} · {made.seconds:.1f} s", style="dim")
414
+ + Text(
415
+ f" {made.width}×{made.height} · {made.model} · seed {made.seed} · {made.seconds:.1f} s", style="dim"
416
+ )
292
417
  )
293
- return made
418
+ if made.files:
419
+ show_preview(agent, made.files[0], made.thumbnail)
420
+
421
+
422
+ def show_preview(agent: Agent, path: Path, thumb: bytes = b"") -> None:
423
+ """The image in the terminal: itself where the terminal can show pictures, else a half-block preview."""
424
+ setting = agent.settings.preview
425
+ how = preview.protocol(setting)
426
+ if how == "off" or (setting == "auto" and not agent.console.is_terminal):
427
+ return
428
+ try:
429
+ if how in ("kitty", "iterm"):
430
+ data = path.read_bytes()
431
+ sequence = preview.kitty(data) if how == "kitty" else preview.iterm(data)
432
+ agent.console.file.write(sequence)
433
+ agent.console.file.flush()
434
+ elif thumb:
435
+ agent.console.print(preview.blocks(thumb))
436
+ except (OSError, preview.PngError):
437
+ pass
438
+
439
+
440
+ def result(agent: Agent, which: str) -> dict:
441
+ """A gallery entry by its number (#3 or 3), or the last one."""
442
+ if not agent.media_results:
443
+ raise MediaError("No images in this session yet.")
444
+ key = which.strip().lstrip("#")
445
+ if not key:
446
+ return agent.media_results[-1]
447
+ if not key.isdigit() or not 1 <= int(key) <= len(agent.media_results):
448
+ raise MediaError(f"There's no image #{key}: this session has #1 to #{len(agent.media_results)} (/gallery).")
449
+ return agent.media_results[int(key) - 1]
450
+
451
+
452
+ def open_file(path: Path) -> str:
453
+ """Open a file in the system's viewer. An error message, or empty."""
454
+ if not path.exists():
455
+ return f"{path} isn't there any more."
456
+ if sys.platform == "darwin":
457
+ command = ["open", str(path)]
458
+ elif sys.platform.startswith("win"):
459
+ os.startfile(str(path)) # type: ignore[attr-defined]
460
+ return ""
461
+ else:
462
+ opener = shutil.which("xdg-open") or shutil.which("gio")
463
+ if not opener:
464
+ return "No program to open it with (xdg-open isn't installed): open the file yourself."
465
+ command = [opener, str(path)] if opener.endswith("xdg-open") else [opener, "open", str(path)]
466
+ try:
467
+ subprocess.Popen(
468
+ command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
469
+ start_new_session=True,
470
+ ) # fmt: skip
471
+ except OSError as e:
472
+ return f"Couldn't open it: {e}"
473
+ return ""
lcode/media.toml CHANGED
@@ -15,6 +15,8 @@
15
15
  # gpu_gb GPU memory it needs, when known better than lcode's estimate (the largest file + 1.5 GB); ComfyUI
16
16
  # fills whatever is free with cached models, so nvidia-smi's peak doesn't tell
17
17
  # measured what it did on real hardware; models without it haven't been run yet
18
+ # quality per preset (fast, balanced, best): [steps, megapixels]
19
+ # negative whether it uses a negative prompt (distilled models don't)
18
20
 
19
21
  [files.flux-2-klein-4b-fp8]
20
22
  folder = "diffusion_models"
@@ -60,6 +62,8 @@ files = { model = "flux-2-klein-4b-fp8", text_encoder = "qwen-3-4b", vae = "flux
60
62
  gpu_gb = 0.0
61
63
  measured = "1024×1024: 3.5 s per image, 14 s for the first with ComfyUI starting (RTX 4080 Laptop, 12 GB)"
62
64
  notes = "fast and small: 4 steps"
65
+ quality = { fast = [4, 0.5], balanced = [4, 1.0], best = [4, 2.0] }
66
+ negative = false
63
67
 
64
68
  [models.z-image-turbo]
65
69
  name = "Z-Image Turbo"
@@ -72,3 +76,5 @@ files = { model = "z-image-turbo-int8", text_encoder = "qwen-3-4b", vae = "flux-
72
76
  gpu_gb = 0.0
73
77
  measured = "1024×1024: 17 s per image (1.9 s per step), 23 s for the first (RTX 4080 Laptop, 12 GB)"
74
78
  notes = "photorealistic, writes text well: 8 steps"
79
+ quality = { fast = [6, 0.5], balanced = [8, 1.0], best = [9, 2.0] }
80
+ negative = false
lcode/media_catalog.py CHANGED
@@ -67,6 +67,13 @@ class MediaModel:
67
67
  gpu_gb: float = 0.0 # measured peak GPU memory; 0 when not measured yet
68
68
  measured: str = ""
69
69
  notes: str = ""
70
+ quality: dict[str, tuple[int, float]] = field(default_factory=dict, hash=False) # preset -> (steps, megapixels)
71
+ negative: bool = False # whether it uses a negative prompt
72
+
73
+ def preset(self, quality: str) -> tuple[int | None, float]:
74
+ """(steps, megapixels) for a quality preset: None steps keeps the workflow's."""
75
+ steps, megapixels = self.quality.get(quality) or self.quality.get("balanced") or (None, 1.0)
76
+ return (int(steps) if steps else None), float(megapixels)
70
77
 
71
78
  @property
72
79
  def size_gb(self) -> float:
@@ -137,6 +144,8 @@ def load() -> Catalog:
137
144
  float(m.get("gpu_gb", 0.0)),
138
145
  m.get("measured", ""),
139
146
  m.get("notes", ""),
147
+ {name: (int(v[0]), float(v[1])) for name, v in (m.get("quality") or {}).items()},
148
+ bool(m.get("negative", False)),
140
149
  )
141
150
  return Catalog(files, models)
142
151
 
lcode/preview.py ADDED
@@ -0,0 +1,152 @@
1
+ """Show an image in the terminal: the picture itself where the terminal can (kitty's graphics protocol, iTerm2's
2
+ inline images), otherwise a small colour preview drawn with half blocks.
3
+
4
+ The half-block preview comes from a thumbnail ComfyUI makes along with the image (a few dozen pixels wide), decoded
5
+ here: lcode needs no imaging library for it. A PNG decoder for 8-bit, non-interlaced images is all that takes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import os
12
+ import struct
13
+ import zlib
14
+ from collections.abc import Mapping
15
+
16
+ from rich.text import Text
17
+
18
+ PROTOCOLS = ("auto", "kitty", "iterm", "blocks", "off")
19
+ COLUMNS = 40 # how wide a preview is, in terminal columns
20
+ THUMB_WIDTH = 64 # pixels: the thumbnail ComfyUI makes for half-block previews
21
+ KITTY_CHUNK = 4096
22
+
23
+
24
+ def protocol(setting: str = "auto", env: Mapping[str, str] = os.environ) -> str:
25
+ """kitty, iterm, blocks or off: what this terminal can show (or what the `preview` setting says)."""
26
+ if setting != "auto":
27
+ return setting
28
+ if env.get("TMUX") or env.get("TERM", "").startswith("screen"):
29
+ return "blocks" # multiplexers need passthrough for images: keep to text
30
+ if env.get("KITTY_WINDOW_ID") or env.get("TERM") == "xterm-kitty" or env.get("TERM_PROGRAM") == "ghostty":
31
+ return "kitty"
32
+ if env.get("TERM_PROGRAM") in ("iTerm.app", "WezTerm") or env.get("LC_TERMINAL") == "iTerm2":
33
+ return "iterm"
34
+ return "blocks"
35
+
36
+
37
+ def kitty(png: bytes, columns: int = COLUMNS) -> str:
38
+ """The escape sequences that show a PNG in kitty (and Ghostty), `columns` wide."""
39
+ data = base64.b64encode(png).decode()
40
+ chunks = [data[i : i + KITTY_CHUNK] for i in range(0, len(data), KITTY_CHUNK)] or [""]
41
+ out = []
42
+ for i, chunk in enumerate(chunks):
43
+ more = 1 if i < len(chunks) - 1 else 0
44
+ head = f"a=T,f=100,c={columns},q=2,m={more}" if i == 0 else f"m={more}"
45
+ out.append(f"\x1b_G{head};{chunk}\x1b\\")
46
+ return "".join(out) + "\n"
47
+
48
+
49
+ def iterm(png: bytes, columns: int = COLUMNS) -> str:
50
+ """The escape sequence that shows an image in iTerm2 (and WezTerm), `columns` wide."""
51
+ data = base64.b64encode(png).decode()
52
+ return f"\x1b]1337;File=inline=1;size={len(png)};width={columns};preserveAspectRatio=1:{data}\x07\n"
53
+
54
+
55
+ # ----------------------------------------------------------------------------- PNG, decoded
56
+
57
+
58
+ class PngError(ValueError):
59
+ pass
60
+
61
+
62
+ def decode_png(data: bytes) -> tuple[int, int, list[list[tuple[int, int, int]]]]:
63
+ """(width, height, rows of RGB pixels) of an 8-bit, non-interlaced PNG (grey, RGB, palette, with alpha)."""
64
+ if not data.startswith(b"\x89PNG\r\n\x1a\n"):
65
+ raise PngError("not a PNG")
66
+ i, idat, palette = 8, [], b""
67
+ width = height = depth = color = interlace = 0
68
+ while i + 8 <= len(data):
69
+ (n,) = struct.unpack(">I", data[i : i + 4])
70
+ kind, body = data[i + 4 : i + 8], data[i + 8 : i + 8 + n]
71
+ if kind == b"IHDR":
72
+ width, height, depth, color, _, _, interlace = struct.unpack(">IIBBBBB", body)
73
+ elif kind == b"PLTE":
74
+ palette = body
75
+ elif kind == b"IDAT":
76
+ idat.append(body)
77
+ elif kind == b"IEND":
78
+ break
79
+ i += 12 + n
80
+ if depth != 8 or interlace or color not in (0, 2, 3, 4, 6):
81
+ raise PngError(f"unsupported PNG (bit depth {depth}, colour type {color}, interlace {interlace})")
82
+ channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[color]
83
+ raw = zlib.decompress(b"".join(idat))
84
+ stride = width * channels
85
+ rows, previous = [], bytearray(stride)
86
+ for y in range(height):
87
+ start = y * (stride + 1)
88
+ kind, line = raw[start], bytearray(raw[start + 1 : start + 1 + stride])
89
+ unfilter(kind, line, previous, channels)
90
+ rows.append(pixels(line, color, channels, palette))
91
+ previous = line
92
+ return width, height, rows
93
+
94
+
95
+ def unfilter(kind: int, line: bytearray, previous: bytearray, bpp: int) -> None:
96
+ if kind == 0:
97
+ return
98
+ for x in range(len(line)):
99
+ left = line[x - bpp] if x >= bpp else 0
100
+ up = previous[x]
101
+ if kind == 1:
102
+ line[x] = (line[x] + left) & 0xFF
103
+ elif kind == 2:
104
+ line[x] = (line[x] + up) & 0xFF
105
+ elif kind == 3:
106
+ line[x] = (line[x] + (left + up) // 2) & 0xFF
107
+ elif kind == 4:
108
+ corner = previous[x - bpp] if x >= bpp else 0
109
+ p = left + up - corner
110
+ pa, pb, pc = abs(p - left), abs(p - up), abs(p - corner)
111
+ line[x] = (line[x] + (left if pa <= pb and pa <= pc else up if pb <= pc else corner)) & 0xFF
112
+ else:
113
+ raise PngError(f"unknown PNG filter {kind}")
114
+
115
+
116
+ def pixels(line: bytearray, color: int, channels: int, palette: bytes) -> list[tuple[int, int, int]]:
117
+ out = []
118
+ for x in range(0, len(line), channels):
119
+ if color in (0, 4):
120
+ out.append((line[x], line[x], line[x]))
121
+ elif color == 3:
122
+ j = line[x] * 3
123
+ out.append(tuple(palette[j : j + 3]) if j + 3 <= len(palette) else (0, 0, 0))
124
+ else:
125
+ out.append((line[x], line[x + 1], line[x + 2]))
126
+ return out
127
+
128
+
129
+ def blocks(png: bytes, columns: int = COLUMNS) -> Text:
130
+ """A colour preview `columns` wide: each character shows two pixels, one above the other (▀)."""
131
+ width, height, rows = decode_png(png)
132
+ columns = max(1, min(columns, width))
133
+ lines = max(1, round(height * columns / width / 2))
134
+ text = Text()
135
+ for row in range(lines):
136
+ for col in range(columns):
137
+ x = min(width - 1, col * width // columns)
138
+ top = rows[min(height - 1, (2 * row) * height // (2 * lines))][x]
139
+ bottom = rows[min(height - 1, (2 * row + 1) * height // (2 * lines))][x]
140
+ text.append("▀", style=f"{hex_color(top)} on {hex_color(bottom)}")
141
+ if row < lines - 1:
142
+ text.append("\n")
143
+ return text
144
+
145
+
146
+ def hex_color(rgb: tuple[int, int, int]) -> str:
147
+ return "#{:02x}{:02x}{:02x}".format(*rgb)
148
+
149
+
150
+ def thumbnail_size(width: int, height: int, thumb: int = THUMB_WIDTH) -> tuple[int, int]:
151
+ """The thumbnail ComfyUI makes for the half-block preview: `thumb` pixels wide, the image's shape."""
152
+ return thumb, max(2, round(thumb * height / width))
lcode/repl.py CHANGED
@@ -79,6 +79,11 @@ COMMANDS = {
79
79
  "/plan": "Plan before changing anything: /plan <request>, or /plan to show the approved plan",
80
80
  "/mode": "What lcode makes: code, image or video, e.g. /mode image",
81
81
  "/permissions": "Permission mode: ask | plan | auto-edit | yolo (Shift+Tab cycles)",
82
+ "/options": "Image mode's options for this session, e.g. /options n=4 ar=16:9 quality=best style=photo",
83
+ "/again": "Image mode: the last request again, with new seeds (flags work: /again --n 4)",
84
+ "/vary": "Image mode: variations of an image, new seeds, e.g. /vary 3 (default: the last; --n 2)",
85
+ "/gallery": "Image mode: this session's images with their settings (/gallery all: every image here)",
86
+ "/open": "Open an image in your viewer, e.g. /open 3 (default: the last)",
82
87
  "/cd": "Change the working directory",
83
88
  "/todos": "Show the current todo list",
84
89
  "/status": "The task sheet: your requests, the plan and notes, files changed, the last check",
@@ -1181,6 +1186,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
1181
1186
  run_safely(agent, arg)
1182
1187
  elif cmd == "/mode":
1183
1188
  mode_command(agent, arg.strip().lower())
1189
+ elif cmd in ("/options", "/again", "/vary", "/gallery", "/open"):
1190
+ image_command(agent, cmd, arg)
1184
1191
  elif cmd == "/permissions":
1185
1192
  if arg in PERMISSION_MODES:
1186
1193
  agent.perms.mode = arg
@@ -1394,8 +1401,100 @@ def respond(agent: Agent, line: str) -> None:
1394
1401
  run_safely(agent, line)
1395
1402
 
1396
1403
 
1397
- def make_image(agent: Agent, prompt: str) -> None:
1398
- """Image mode: ComfyUI makes the image; Esc or Ctrl+C cancels it there too."""
1404
+ def image_command(agent: Agent, cmd: str, arg: str) -> None:
1405
+ """/options, /again, /vary, /gallery and /open: image mode's follow-ups on its results."""
1406
+ from lcode import imageopts
1407
+
1408
+ c = agent.console
1409
+ if cmd == "/options":
1410
+ if not arg.strip():
1411
+ c.print(f"Image options: [bold]{escape(agent.image_options.describe())}[/]")
1412
+ c.print(
1413
+ '[dim]Set them with /options n=4 ar=16:9 seed=42 quality=best style=photo no="text"; '
1414
+ f"styles: {', '.join(imageopts.STYLES)}. Flags in a request (--n 4) win for that request.[/]"
1415
+ )
1416
+ return
1417
+ try:
1418
+ agent.image_options = imageopts.set_values(agent.image_options, imageopts.parse_assignments(arg))
1419
+ except imageopts.OptionError as e:
1420
+ c.print(f"[red]{escape(str(e))}[/]")
1421
+ return
1422
+ c.print(f"Image options: [bold]{escape(agent.image_options.describe())}[/]")
1423
+ return
1424
+ if cmd == "/gallery":
1425
+ gallery(agent, everything=arg.strip() == "all")
1426
+ return
1427
+ if cmd == "/open":
1428
+ try:
1429
+ entry = media.result(agent, arg)
1430
+ except media.MediaError as e:
1431
+ c.print(f"[yellow]{escape(str(e))}[/]")
1432
+ return
1433
+ problem = media.open_file(Path(entry["path"]))
1434
+ c.print(f"[yellow]{escape(problem)}[/]" if problem else f"[dim]Opened #{entry['n']}.[/]")
1435
+ return
1436
+ if agent.settings.mode != "image":
1437
+ c.print("[yellow]/again and /vary work in image mode: /mode image.[/]")
1438
+ return
1439
+ try:
1440
+ number, _ = imageopts.parse(arg, imageopts.Options()) # what isn't a flag: the image's number
1441
+ except imageopts.OptionError as e:
1442
+ c.print(f"[red]{escape(str(e))}[/]")
1443
+ return
1444
+ if cmd == "/again":
1445
+ if agent.last_request is None:
1446
+ c.print("[yellow]Nothing to do again yet: describe an image first.[/]")
1447
+ return
1448
+ prompt, options = agent.last_request
1449
+ options = imageopts.replace_seed(options)
1450
+ else:
1451
+ try:
1452
+ entry = media.result(agent, number)
1453
+ except media.MediaError as e:
1454
+ c.print(f"[yellow]{escape(str(e))}[/]")
1455
+ return
1456
+ prompt = entry["prompt"]
1457
+ options = imageopts.Options.from_json(entry.get("options") or {})
1458
+ options = imageopts.replace_seed(options, n=4)
1459
+ c.print(f"[dim]Variations of #{entry['n']}: {escape(prompt)}[/]")
1460
+ _, options = imageopts.parse(arg, options) # flags given here win
1461
+ make_image(agent, prompt, options)
1462
+
1463
+
1464
+ def gallery(agent: Agent, everything: bool = False) -> None:
1465
+ """The session's images (or every image in the media folder), with what made them."""
1466
+ import json as json_module
1467
+
1468
+ c = agent.console
1469
+ if everything:
1470
+ folder = media.folder(agent)
1471
+ entries = []
1472
+ for sidecar in sorted(folder.glob("*.json"), reverse=True)[:50]:
1473
+ try:
1474
+ meta = json_module.loads(sidecar.read_text())
1475
+ except (OSError, ValueError):
1476
+ continue
1477
+ entries.append({"path": str(sidecar.with_name(meta.get("file", sidecar.stem + ".png"))), **meta})
1478
+ if not entries:
1479
+ c.print(f"[dim]No images in {media.shown(agent, folder)} yet.[/]")
1480
+ return
1481
+ c.print(f"[bold]{len(entries)} latest image(s) in {escape(media.shown(agent, folder))}[/]")
1482
+ else:
1483
+ entries = agent.media_results
1484
+ if not entries:
1485
+ c.print("[dim]No images in this session yet: describe one. /gallery all lists every image here.[/]")
1486
+ return
1487
+ for entry in entries:
1488
+ number = f"#{entry['n']:<3}" if "n" in entry else " "
1489
+ c.print(
1490
+ f" [bold green]{number}[/] {escape(media.shown(agent, Path(entry['path'])))}\n"
1491
+ f" [dim]{escape(entry.get('prompt', ''))} · {entry.get('width')}×{entry.get('height')} · "
1492
+ f"{escape(str(entry.get('model', '')))} · seed {entry.get('seed')}[/]"
1493
+ )
1494
+
1495
+
1496
+ def make_image(agent: Agent, prompt: str, options=None) -> None:
1497
+ """Image mode: ComfyUI makes the images; Esc or Ctrl+C cancels them there too."""
1399
1498
  steering = Steering()
1400
1499
  reader = KeyReader(steering)
1401
1500
  reading = agent.interactive and reader.start() # Esc sets steering.pause: here it cancels
@@ -1405,7 +1504,11 @@ def make_image(agent: Agent, prompt: str) -> None:
1405
1504
  return answer.strip().lower() in ("y", "yes")
1406
1505
 
1407
1506
  try:
1408
- media.generate_image(agent, prompt, cancelled=lambda: steering.pause, ask=ask if agent.interactive else None)
1507
+ asking = ask if agent.interactive else None
1508
+ if options is None:
1509
+ media.request(agent, prompt, cancelled=lambda: steering.pause, ask=asking)
1510
+ else:
1511
+ media.make(agent, prompt, options, cancelled=lambda: steering.pause, ask=asking)
1409
1512
  except media.MediaError as e:
1410
1513
  agent.console.print(f"[yellow]{escape(str(e))}[/]")
1411
1514
  except KeyboardInterrupt: # while ComfyUI was starting: nothing queued yet
@@ -1419,6 +1522,7 @@ def make_image(agent: Agent, prompt: str) -> None:
1419
1522
  if typed:
1420
1523
  agent.unsent = typed
1421
1524
  agent.console.print(Text("✎ What you typed is in the prompt below.", style="cyan"))
1525
+ agent.save() # the gallery and options carry over when the session is resumed
1422
1526
 
1423
1527
 
1424
1528
  def preload(agent: Agent, session: PromptSession | None = None) -> None:
lcode/sessions.py CHANGED
@@ -24,7 +24,7 @@ class SessionInfo:
24
24
  title: str # the first request, shortened
25
25
  model: str
26
26
  updated: float # modification time of the session file
27
- turns: int # number of requests from the user
27
+ turns: int # number of requests from the user (and images made, in image mode)
28
28
 
29
29
  @property
30
30
  def label(self) -> str:
@@ -69,7 +69,7 @@ def read_info(path: Path) -> SessionInfo | None:
69
69
  title=data.get("title") or title_from(messages),
70
70
  model=data.get("model", ""),
71
71
  updated=updated,
72
- turns=sum(1 for m in messages if m.get("role") == "user"),
72
+ turns=sum(1 for m in messages if m.get("role") == "user") + len((data.get("media") or {}).get("results") or []),
73
73
  )
74
74
 
75
75
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.41.0
3
+ Version: 0.42.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,9 +1,9 @@
1
- lcode/__init__.py,sha256=cP-2ftJ2hz9SKhg5hKS4FosPfEa3qxeHu6hAb4URJQ8,473
1
+ lcode/__init__.py,sha256=v3apJPFRjkc0xQqftHToQJR2cWw-oucwV0aezidZmyA,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
5
- lcode/agent.py,sha256=-RZXqheMdbwjRkwMPGBnj_kkRfrAUYkqOLMc888gXgo,87528
6
- lcode/api.py,sha256=jQmx-ddJC6MoNcRcaWoERs2Ci6KYzBto9GxdVBE7JkU,17632
5
+ lcode/agent.py,sha256=DmKlZ86NPzicwc_pXDEJ3_vgRwri2XknFq9M01f2Sgk,88527
6
+ lcode/api.py,sha256=efsUuIZneUc6Ar6KWerKAoMlMFOlSUtLX4_quplr3t8,17664
7
7
  lcode/backends.py,sha256=343alwyDZin-bEhb5-Es4f8ek1oBQUzEvV6RFWR1VZo,21160
8
8
  lcode/bench.py,sha256=HxeUfy9Kv_0Rug1HAlDLyHYfYd9dw2bMgbXsvUy9nd4,43096
9
9
  lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
@@ -11,12 +11,12 @@ 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=haYYIPhadKavGYEhh8YWAaWrPMcfzrpT2i6638f4xjQ,12933
14
- lcode/cli.py,sha256=WuQW4Ao_TbJ0HKFOaFtJnyjvv-lwGYRg_tb0vI-zIg0,51296
14
+ lcode/cli.py,sha256=3HPENwGw8rufYAgfmCHVK4R3gkEU2uDfgUyF1gnadzw,51385
15
15
  lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
16
16
  lcode/cloud.py,sha256=q5Dl5GuqWvKzBCIso-eh6yomvEOsnQ0LW8ZgaAGwBxc,2131
17
17
  lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
18
- lcode/comfy.py,sha256=tCXSWH9Lk0MsU6031-a6LSOTNSZ1h9G3ZvFwh3d_LPo,26357
19
- lcode/config.py,sha256=EwMp3h0kHoLqHJc1sGCsXYjzj79gpg2HgMhGz1Ol1q0,13569
18
+ lcode/comfy.py,sha256=SpIoY9fSvtm1TVtHTkUZ5W59RG55vtMncI-bRogqGBY,26490
19
+ lcode/config.py,sha256=F7CRPdlqW6i9CSapqeU83ClOHOsf-Mxx1sWGXRYI1ac,13861
20
20
  lcode/context.py,sha256=wgK16ZcNNnq4ngHx0EmtyKHDFTImciGEmtC5NP8tKJQ,20435
21
21
  lcode/escalate.py,sha256=f-_6_moPaHi2kLuJw2JtkzvTkCl6DoBQgYBOCYLhunM,15120
22
22
  lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
@@ -24,13 +24,14 @@ lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
24
24
  lcode/gitflow.py,sha256=6j_nnCBZXkX1qhTI7K4CyZ5D0NAZLpH96weFEo64AkQ,27913
25
25
  lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
26
26
  lcode/hooks.py,sha256=K0itOiAuH4rxndJ9gne_uCrqby6oWAzFPRrfB3tCq-E,9324
27
+ lcode/imageopts.py,sha256=CQBvuEE-aK5C0wFqgTp1jxcpw-bOU2DJZpBYuqyA88I,7791
27
28
  lcode/jobs.py,sha256=P1P3FqI6EqL4LgdMX9YGZwCW0Bh5BRXFA_FnQxpHNjE,5618
28
29
  lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
29
30
  lcode/lsp.py,sha256=NtSW7xapv7fg2HAQLuYK1nwIqT7nG0LK1hiTpOmla_4,26086
30
31
  lcode/matching.py,sha256=2N3_qwKJ5gd8el2m_y_egKkFM9rsF1eUhYJcRCVIp98,14015
31
- lcode/media.py,sha256=laRxRuBPGoOK1e2W6cjNHySps8OO9N6UiauuAtGmlyk,11870
32
- lcode/media.toml,sha256=8vqFwqE8ECizDkUlnWGLfhcx94vEG1WfRCpJPHTi3UQ,3225
33
- lcode/media_catalog.py,sha256=rtJPS3S7ZgJjaY3HIRerhf4ciqzXt4BsOikoQavzPAI,13076
32
+ lcode/media.py,sha256=FHvghSTDsJxXhhpivdlpHrm4FyLEH7HeG2StHPo7bj0,19012
33
+ lcode/media.toml,sha256=Lv82r8YIA8CFEybXYV3qHtKGYJDk9hCflVXBG_xlfe0,3545
34
+ lcode/media_catalog.py,sha256=y7kR4hzLW6b4tRQjk7b1_Oqq7zc5SWEG7Wux7pksCXI,13716
34
35
  lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
35
36
  lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
36
37
  lcode/modes.py,sha256=Ke_mEeYF_SskbVUXMpJMeV5d5fFEQ-Vb-MKp4SIvT0Y,4990
@@ -38,16 +39,17 @@ lcode/notify.py,sha256=uieDeXe7XwuVHjOk4tqVyZEW_85XWkoWQmFOWEf77oc,1538
38
39
  lcode/ollama.py,sha256=xFHU2XW47ANJ34-1aeSMQVj28GhqcWiFi6zASH9FiYY,8053
39
40
  lcode/permissions.py,sha256=q7CjDsc_Xrblw7t3_BbxA5LUjxFSswGs1RiJmtMi0po,12288
40
41
  lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
42
+ lcode/preview.py,sha256=ATzEs6yaI1__bfo2_juWznrTDXUD4QTc_aQMPRCytgA,6253
41
43
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
42
44
  lcode/repeats.py,sha256=OTrPdb6BKArDLQEgHaXmJZ-tIFsY_1bk4P0wzzANKLY,5964
43
- lcode/repl.py,sha256=Jjs45rvONRX2F3hx9Q-o3WkcepC_rKIqOE9okdlhNe8,62649
45
+ lcode/repl.py,sha256=c_XpqiOfQjnQ6Ng1Zff3yqBOb5QJrlzQxDnHo6P_2d4,67500
44
46
  lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
45
47
  lcode/residency.py,sha256=B9pwqG6yx5aQD2M5xyQ-bMnYmhD7fv3wCVw_X6VIeAo,7012
46
48
  lcode/review.py,sha256=0QBIS-R1B2b3zqx5TcC1qZptpKBbrDhYvMKSNCaDAUo,4686
47
49
  lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
48
50
  lcode/screenshots.py,sha256=pmrw_tp8eXzNyCZa5uot5rTgsxqsi3AdmkGn-XF9saY,57056
49
51
  lcode/secretscan.py,sha256=6Euvfk2gCtK7lZbuQD84zgWuSECi6OnSw8V5rFu1tfU,13575
50
- lcode/sessions.py,sha256=ojkpP453JyIll8_iFeJgW4BG4Zz2FKMRsLiij6spiTo,4185
52
+ lcode/sessions.py,sha256=ri3MZXGzkbMyVmMm9sZlYc0HWh56ORnRVch2mA9N16w,4272
51
53
  lcode/steer.py,sha256=0vaqLxgRKgC0yGp8xoQfV1f9VEOzH3U4rCIb74eFnZ8,12127
52
54
  lcode/subagents.py,sha256=thR2ZzFZ1DeoAf0Ctr_oeKjYajhvd3uRs2kGdw6LlJM,29349
53
55
  lcode/tools.py,sha256=lpCIlaabydbmutaXWuOZWS4X-Z_7aIsyNUQdHJzkSC4,68902
@@ -74,8 +76,8 @@ lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
74
76
  lcode/workflows/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
75
77
  lcode/workflows/flux2-klein-4b-t2i.json,sha256=yGrYDT8pG2bns9DS1JavhlX5fwhDgIKfAwrHrRCyE8k,1788
76
78
  lcode/workflows/z-image-turbo-t2i.json,sha256=sHsL7xnnuULOuWGYlQrDl4jOTySdAmb2T82c_cChv0s,1585
77
- lcode_cli-0.41.0.dist-info/METADATA,sha256=MitknSeSrdiXm2qjDMKu5eT_xBdeAcFIqxT9oEfvwhM,13386
78
- lcode_cli-0.41.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
79
- lcode_cli-0.41.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
80
- lcode_cli-0.41.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
81
- lcode_cli-0.41.0.dist-info/RECORD,,
79
+ lcode_cli-0.42.0.dist-info/METADATA,sha256=k7PWFqR8Oqms3gNa4-HR6NMUUVSsfS2eupoJvVdPO0w,13386
80
+ lcode_cli-0.42.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
81
+ lcode_cli-0.42.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
82
+ lcode_cli-0.42.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
83
+ lcode_cli-0.42.0.dist-info/RECORD,,