wrencode 0.1.4__tar.gz → 0.1.4.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.
@@ -17,6 +17,7 @@ dist/
17
17
  .venv/
18
18
  venv/
19
19
  env/
20
+ .tmp/
20
21
 
21
22
  # WrenCode local state (when pointed at a repo-local config dir)
22
23
  .wrencode/
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wrencode
3
- Version: 0.1.4
3
+ Version: 0.1.4.2
4
4
  Summary: A minimal agentic coding assistant in a single Python file
5
5
  Project-URL: Homepage, https://github.com/almostly/wrencode
6
6
  Project-URL: Repository, https://github.com/almostly/wrencode
7
7
  Project-URL: Issues, https://github.com/almostly/wrencode/issues
8
- Author: Denis Burakov
8
+ Author: Almostly
9
9
  License-Expression: MIT
10
10
  Keywords: agent,ai,anthropic,cli,coding-assistant,llm,mlx,ollama,openai
11
11
  Classifier: Development Status :: 4 - Beta
@@ -55,6 +55,12 @@ saved choice, e.g. for CI.
55
55
  The standalone binary can't bundle the heavy ML stack, so the local-weights
56
56
  backends (`mlx`, `transformers`) are only offered when running from source.
57
57
 
58
+ The default local models are
59
+ [`deburky/gpt-oss-claude-code`](https://huggingface.co/deburky/gpt-oss-claude-code)
60
+ (transformers) and
61
+ [`deburky/gpt-oss-claude-mlx`](https://huggingface.co/deburky/gpt-oss-claude-mlx)
62
+ (MLX) — override either with `MODEL=...`.
63
+
58
64
  ## Tools
59
65
 
60
66
  The agent has access to seven tools:
@@ -260,4 +266,8 @@ To clear history: use `/c` in the session, or delete `~/.wrencode/history.json`
260
266
 
261
267
  ## License
262
268
 
263
- MIT - Copyright 2026 Denis Burakov.
269
+ MIT - Copyright 2026 Almostly.
270
+
271
+ -----
272
+
273
+ ![Almostly](assets/almostly-badge.svg)
@@ -32,6 +32,12 @@ saved choice, e.g. for CI.
32
32
  The standalone binary can't bundle the heavy ML stack, so the local-weights
33
33
  backends (`mlx`, `transformers`) are only offered when running from source.
34
34
 
35
+ The default local models are
36
+ [`deburky/gpt-oss-claude-code`](https://huggingface.co/deburky/gpt-oss-claude-code)
37
+ (transformers) and
38
+ [`deburky/gpt-oss-claude-mlx`](https://huggingface.co/deburky/gpt-oss-claude-mlx)
39
+ (MLX) — override either with `MODEL=...`.
40
+
35
41
  ## Tools
36
42
 
37
43
  The agent has access to seven tools:
@@ -237,4 +243,8 @@ To clear history: use `/c` in the session, or delete `~/.wrencode/history.json`
237
243
 
238
244
  ## License
239
245
 
240
- MIT - Copyright 2026 Denis Burakov.
246
+ MIT - Copyright 2026 Almostly.
247
+
248
+ -----
249
+
250
+ ![Almostly](assets/almostly-badge.svg)
@@ -9,7 +9,7 @@ description = "A minimal agentic coding assistant in a single Python file"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
11
11
  license = "MIT"
12
- authors = [{ name = "Denis Burakov" }]
12
+ authors = [{ name = "Almostly" }]
13
13
  keywords = [
14
14
  "agent",
15
15
  "llm",
@@ -8,14 +8,17 @@ HuggingFace Transformers, Anthropic, OpenAI, OpenRouter, and local proxy.
8
8
  Provides a tool-calling agent loop with file read/write/edit, glob, grep,
9
9
  and bash — enough to autonomously navigate and modify a codebase.
10
10
 
11
- Copyright 2026 Denis Burakov. All Rights Reserved.
11
+ Copyright (c) 2026 Almostly
12
12
 
13
13
  Permission is hereby granted, free of charge, to any person obtaining a copy
14
14
  of this software and associated documentation files (the "Software"), to deal
15
15
  in the Software without restriction, including without limitation the rights
16
16
  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
17
  copies of the Software, and to permit persons to whom the Software is
18
- furnished to do so.
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
19
22
 
20
23
  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
24
  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
@@ -29,6 +32,7 @@ THE SOFTWARE.
29
32
  # flake8: noqa: E501, E203
30
33
 
31
34
  import contextlib
35
+ import ast
32
36
  import getpass
33
37
  import glob as globlib
34
38
  import json
@@ -67,7 +71,7 @@ for _dir in (os.path.dirname(os.path.abspath(__file__)), os.getcwd()):
67
71
  # -----------------------------------------------------------------------------------------------
68
72
  # Backend Configuration
69
73
  # -----------------------------------------------------------------------------------------------
70
- WRENCODE_VERSION = "0.1.4"
74
+ WRENCODE_VERSION = "0.1.4.2"
71
75
 
72
76
  # Per-backend defaults. "kind" controls how a backend is treated:
73
77
  # api - hosted HTTP API, needs an API key
@@ -80,42 +84,42 @@ BACKEND_SPECS: dict[str, dict[str, str]] = {
80
84
  "model": "claude-haiku-4-5-20251001",
81
85
  "key_env": "ANTHROPIC_API_KEY",
82
86
  "api_base": "https://api.anthropic.com/v1/messages",
83
- "label": "Anthropic Claude (API key)",
87
+ "label": "Anthropic Claude (API key)",
84
88
  },
85
89
  "openai": {
86
90
  "kind": "api",
87
91
  "model": "gpt-4o-mini",
88
92
  "key_env": "OPENAI_API_KEY",
89
93
  "api_base": "https://api.openai.com/v1/chat/completions",
90
- "label": "OpenAI GPT (API key)",
94
+ "label": "OpenAI GPT (API key)",
91
95
  },
92
96
  "openrouter": {
93
97
  "kind": "api",
94
98
  "model": "anthropic/claude-3-haiku",
95
99
  "key_env": "OPENROUTER_API_KEY",
96
100
  "api_base": "https://openrouter.ai/api/v1/chat/completions",
97
- "label": "OpenRouter — any model (API key)",
101
+ "label": "OpenRouter — any model (API key)",
98
102
  },
99
103
  "local": {
100
104
  "kind": "local-proxy",
101
105
  "model": "gpt-oss-20b",
102
106
  "key_env": "LOCAL_API_KEY",
103
- "label": "Local proxy (Anthropic-compatible server on localhost)",
107
+ "label": "Local proxy (Anthropic-compatible server on localhost)",
104
108
  },
105
109
  "ollama": {
106
110
  "kind": "local-proxy",
107
111
  "model": "llama3.2",
108
- "label": "Ollama (local models via `ollama serve`)",
112
+ "label": "Ollama (local models via `ollama serve`)",
109
113
  },
110
114
  "transformers": {
111
115
  "kind": "local-ml",
112
116
  "model": "deburky/gpt-oss-claude-code",
113
- "label": "HuggingFace Transformers (CPU/GPU, source install)",
117
+ "label": "HuggingFace Transformers (CPU/GPU, source install)",
114
118
  },
115
119
  "mlx": {
116
120
  "kind": "local-ml",
117
- "model": "mlx-community/Qwen2.5-3B-Instruct-4bit",
118
- "label": "Apple Silicon via MLX (source install)",
121
+ "model": "deburky/gpt-oss-claude-mlx",
122
+ "label": "Apple Silicon via MLX (source install)",
119
123
  },
120
124
  }
121
125
 
@@ -191,6 +195,7 @@ MAX_READ_LINES = int(os.environ.get("MAX_READ_LINES", "800"))
191
195
  GREP_MAX = int(os.environ.get("GREP_MAX_MATCHES", "80"))
192
196
  BASH_TIMEOUT = int(os.environ.get("BASH_TIMEOUT", "120"))
193
197
  MAX_OUT = int(os.environ.get("MAX_TOOL_OUTPUT_CHARS", "48000"))
198
+ TOOL_ERROR_REPEAT_LIMIT = int(os.environ.get("TOOL_ERROR_REPEAT_LIMIT", "3"))
194
199
  _GLOB_SKIP: set[str] = {
195
200
  s
196
201
  for s in os.environ.get(
@@ -339,7 +344,7 @@ def edit(args: dict[str, Any]) -> str:
339
344
  """Replace a unique string in a file with a new string."""
340
345
  path = resolve_tool_path(_require_str(args, "path"))
341
346
  old = _require_str(args, "old")
342
- new = args.get("new", "")
347
+ new = str(args.get("new", ""))
343
348
  if not path.is_file():
344
349
  return f"error: not a file: {path}"
345
350
  if path.stat().st_size > MAX_READ_BYTES:
@@ -350,14 +355,27 @@ def edit(args: dict[str, Any]) -> str:
350
355
  count = text.count(old)
351
356
  if not args.get("all") and count > 1:
352
357
  return f"error: old_string appears {count} times (use all=true)"
353
- if not confirm(f"Edit {path!r}"):
354
- return "cancelled"
355
- path.write_text(
356
- text.replace(old, str(new))
358
+ updated = (
359
+ text.replace(old, new)
357
360
  if args.get("all")
358
- else text.replace(old, str(new), 1),
359
- encoding="utf-8",
361
+ else text.replace(old, new, 1)
360
362
  )
363
+ if updated == text:
364
+ return "error: edit produced no change"
365
+ suffix = path.suffix.lower()
366
+ if suffix == ".py":
367
+ try:
368
+ ast.parse(updated)
369
+ except SyntaxError as exc:
370
+ return f"error: edit would make invalid Python: {exc}"
371
+ elif suffix == ".json":
372
+ try:
373
+ json.loads(updated)
374
+ except json.JSONDecodeError as exc:
375
+ return f"error: edit would make invalid JSON: {exc}"
376
+ if not confirm(f"Edit {path!r}"):
377
+ return "cancelled"
378
+ path.write_text(updated, encoding="utf-8")
361
379
  return "ok"
362
380
 
363
381
 
@@ -383,9 +401,15 @@ def glob(args: dict[str, Any]) -> str:
383
401
  def grep(args: dict[str, Any]) -> str:
384
402
  """Search files for a regex pattern using ripgrep."""
385
403
  pat = _require_str(args, "pat")
386
- root = resolve_tool_path(args.get("path", "."))
387
- if not root.is_dir():
388
- return f"error: grep path must be a directory: {root}"
404
+ target_raw = args.get("path", ".")
405
+ try:
406
+ target = resolve_tool_path(target_raw)
407
+ except Exception as exc:
408
+ return f"error: invalid grep path {target_raw!r}: {exc}"
409
+ if not target.exists():
410
+ return f"error: grep path not found: {target}"
411
+ search_dir = target if target.is_dir() else target.parent
412
+ scope = "." if target.is_dir() else target.name
389
413
  rg = shutil.which("rg")
390
414
  grep_bin = shutil.which("grep")
391
415
  if not rg and not grep_bin:
@@ -393,14 +417,14 @@ def grep(args: dict[str, Any]) -> str:
393
417
  tool = rg or grep_bin
394
418
  assert tool is not None # guaranteed by the check above
395
419
  cmd = (
396
- [tool, "-n", "--color", "never", "--no-heading", "-e", pat, "."]
420
+ [tool, "-n", "--color", "never", "--no-heading", "-e", pat, scope]
397
421
  if rg
398
- else [tool, "-R", "-n", "-I", "--", pat, "."]
422
+ else [tool, "-R", "-n", "-I", "--", pat, scope]
399
423
  )
400
424
  try:
401
425
  proc = subprocess.run(
402
426
  cmd,
403
- cwd=str(root),
427
+ cwd=str(search_dir),
404
428
  capture_output=True,
405
429
  text=True,
406
430
  timeout=90,
@@ -575,9 +599,54 @@ def flatten_content(content: Any) -> str:
575
599
  return "\n".join(parts)
576
600
 
577
601
 
602
+ # Lightweight, language-agnostic code coloring — no pygments, keeps the binary lean.
603
+ _CODE_TOKEN = re.compile(
604
+ r"(?P<comment>#[^\n]*|//[^\n]*)"
605
+ r"|(?P<string>\"[^\"\n]*\"|'[^'\n]*'|`[^`\n]*`)"
606
+ r"|(?P<num>\b\d[\d_.]*\b)"
607
+ r"|(?P<kw>\b(?:def|class|return|import|from|if|elif|else|for|while|try|except|"
608
+ r"finally|with|as|in|not|and|or|is|lambda|yield|async|await|pass|break|continue|"
609
+ r"raise|global|nonlocal|assert|True|False|None|const|let|var|function|export|"
610
+ r"default|new|this|fn|match|struct|enum|pub|use|mut|public|private|static|void)\b)"
611
+ )
612
+
613
+
614
+ def _highlight_code(code: str) -> str:
615
+ """Apply light ANSI syntax coloring to a code block (best-effort, any language)."""
616
+
617
+ def color(m: "re.Match[str]") -> str:
618
+ g = m.lastgroup
619
+ if g == "comment":
620
+ return f"{DIM}{m.group()}{RESET}"
621
+ if g == "string":
622
+ return f"{GREEN}{m.group()}{RESET}"
623
+ if g == "num":
624
+ return f"{YELLOW}{m.group()}{RESET}"
625
+ if g == "kw":
626
+ return f"{BLUE}{m.group()}{RESET}"
627
+ return m.group()
628
+
629
+ return _CODE_TOKEN.sub(color, code)
630
+
631
+
578
632
  def render_markdown(text: str) -> str:
579
- """Render basic markdown bold syntax to terminal bold escape codes."""
580
- return re.sub(r"\*\*(.+?)\*\*", f"{BOLD}\\1{RESET}", text)
633
+ """Render fenced code blocks (lightly highlighted), inline code, and bold."""
634
+ blocks: list[str] = []
635
+
636
+ def stash(m: "re.Match[str]") -> str:
637
+ lang = m.group(1) or ""
638
+ body = _highlight_code(m.group(2).rstrip("\n"))
639
+ head = f"{DIM}┌─ {lang}{RESET}\n" if lang else f"{DIM}┌─{RESET}\n"
640
+ bordered = "\n".join(f"{DIM}│{RESET} {ln}" for ln in body.split("\n"))
641
+ blocks.append(f"\n{head}{bordered}\n{DIM}└─{RESET}")
642
+ return f"\x00B{len(blocks) - 1}\x00"
643
+
644
+ text = re.sub(r"```(\w*)\n?(.*?)```", stash, text, flags=re.DOTALL)
645
+ text = re.sub(r"`([^`\n]+)`", f"{CYAN}\\1{RESET}", text)
646
+ text = re.sub(r"\*\*(.+?)\*\*", f"{BOLD}\\1{RESET}", text)
647
+ for i, b in enumerate(blocks):
648
+ text = text.replace(f"\x00B{i}\x00", b)
649
+ return text
581
650
 
582
651
 
583
652
  # -----------------------------------------------------------------------------------------------
@@ -631,6 +700,56 @@ def _tool_call_complete(text: str) -> int:
631
700
  return last
632
701
 
633
702
 
703
+ def _balance_json_object(raw: str) -> str:
704
+ """Best-effort brace balancing for a JSON object string."""
705
+ depth = 0
706
+ in_string = False
707
+ escaped = False
708
+ for ch in raw:
709
+ if escaped:
710
+ escaped = False
711
+ continue
712
+ if ch == "\\":
713
+ escaped = True
714
+ continue
715
+ if ch == '"':
716
+ in_string = not in_string
717
+ continue
718
+ if in_string:
719
+ continue
720
+ if ch == "{":
721
+ depth += 1
722
+ elif ch == "}":
723
+ depth -= 1
724
+ if depth > 0:
725
+ raw += "}" * depth
726
+ return raw
727
+
728
+
729
+ def _parse_tool_payload(raw_payload: str) -> Optional[dict[str, Any]]:
730
+ """Best-effort parse of a <tool_call> payload."""
731
+ raw = raw_payload.strip()
732
+ if not raw:
733
+ return None
734
+ with contextlib.suppress(Exception):
735
+ parsed = json.loads(raw)
736
+ if isinstance(parsed, dict):
737
+ return parsed
738
+ with contextlib.suppress(Exception):
739
+ parsed = json.loads(_balance_json_object(raw))
740
+ if isinstance(parsed, dict):
741
+ return parsed
742
+ with contextlib.suppress(Exception):
743
+ parsed = ast.literal_eval(raw)
744
+ if isinstance(parsed, dict):
745
+ return parsed
746
+ with contextlib.suppress(Exception):
747
+ parsed = ast.literal_eval(_balance_json_object(raw))
748
+ if isinstance(parsed, dict):
749
+ return parsed
750
+ return None
751
+
752
+
634
753
  def parse_tool_calls(text: str) -> list[dict[str, Any]]:
635
754
  """Parse all <tool_call> blocks from model output into structured dicts.
636
755
 
@@ -641,10 +760,15 @@ def parse_tool_calls(text: str) -> list[dict[str, Any]]:
641
760
  """
642
761
  calls: list[dict[str, Any]] = []
643
762
  pos = 0
644
- while (start := text.find("<tool_call>", pos)) != -1:
763
+ open_tag = "<tool_call>"
764
+ close_tag = "</tool_call>"
765
+ while (start := text.find(open_tag, pos)) != -1:
645
766
  brace = text.find("{", start)
767
+ close = text.find(close_tag, start)
646
768
  if brace == -1:
647
- break
769
+ # malformed block with no JSON payload; skip and keep scanning
770
+ pos = close + len(close_tag) if close != -1 else start + len(open_tag)
771
+ continue
648
772
  depth, end = 0, -1
649
773
  for i, ch in enumerate(text[brace:], brace):
650
774
  if ch == "{":
@@ -654,20 +778,24 @@ def parse_tool_calls(text: str) -> list[dict[str, Any]]:
654
778
  if depth == 0:
655
779
  end = i + 1
656
780
  break
657
- if end == -1:
658
- break # JSON not yet complete
659
- with contextlib.suppress(Exception):
660
- d = json.loads(text[brace:end])
661
- if d.get("tool") in TOOLS:
662
- calls.append(
663
- {
664
- "type": "tool_use",
665
- "id": f"call_{len(calls)}",
666
- "name": d["tool"],
667
- "input": d.get("args", {}),
668
- }
669
- )
670
- pos = end
781
+ payload: Optional[dict[str, Any]] = None
782
+ if end != -1:
783
+ payload = _parse_tool_payload(text[brace:end])
784
+ pos = end
785
+ elif close != -1 and close > brace:
786
+ payload = _parse_tool_payload(text[brace:close])
787
+ pos = close + len(close_tag)
788
+ else:
789
+ break # JSON may still be streaming
790
+ if payload and payload.get("tool") in TOOLS:
791
+ calls.append(
792
+ {
793
+ "type": "tool_use",
794
+ "id": f"call_{len(calls)}",
795
+ "name": payload["tool"],
796
+ "input": payload.get("args", {}),
797
+ }
798
+ )
671
799
  return calls
672
800
 
673
801
 
@@ -1138,6 +1266,8 @@ def run_agent_turn(
1138
1266
  0 means unlimited, preserving the interactive default.
1139
1267
  """
1140
1268
  iters = 0
1269
+ last_tool_error: Optional[str] = None
1270
+ repeated_tool_error_count = 0
1141
1271
  while True:
1142
1272
  if max_iters and iters >= max_iters:
1143
1273
  print(f"{YELLOW}(stopped after {max_iters} iterations){RESET}")
@@ -1167,6 +1297,7 @@ def run_agent_turn(
1167
1297
  if not tool_calls:
1168
1298
  break
1169
1299
  tool_results: list[dict[str, Any]] = []
1300
+ stop_due_to_repeated_error = False
1170
1301
  for tc in tool_calls:
1171
1302
  arg_preview = (
1172
1303
  str(list(tc["input"].values())[0])[:50]
@@ -1200,10 +1331,27 @@ def run_agent_turn(
1200
1331
  "content": result,
1201
1332
  }
1202
1333
  )
1334
+ if result.startswith("error:"):
1335
+ if result == last_tool_error:
1336
+ repeated_tool_error_count += 1
1337
+ else:
1338
+ last_tool_error = result
1339
+ repeated_tool_error_count = 1
1340
+ if repeated_tool_error_count >= TOOL_ERROR_REPEAT_LIMIT:
1341
+ print(
1342
+ f"{YELLOW}Stopping: repeated identical tool error {repeated_tool_error_count} times.{RESET}"
1343
+ )
1344
+ stop_due_to_repeated_error = True
1345
+ break
1346
+ else:
1347
+ last_tool_error = None
1348
+ repeated_tool_error_count = 0
1203
1349
  if BACKEND == "anthropic":
1204
1350
  messages.append({"role": "user", "content": tool_results})
1205
1351
  else:
1206
1352
  messages.extend(tool_results)
1353
+ if stop_due_to_repeated_error:
1354
+ break
1207
1355
  continue
1208
1356
 
1209
1357
  # XML tool call path (mlx, transformers, openrouter, local)
@@ -1219,6 +1367,7 @@ def run_agent_turn(
1219
1367
  [{"type": "text", "text": display_text}] if display_text else []
1220
1368
  )
1221
1369
  xml_tool_results: list[dict[str, Any]] = []
1370
+ stop_due_to_repeated_error = False
1222
1371
  for tc in xml_tool_calls:
1223
1372
  arg_preview = (
1224
1373
  str(list(tc["input"].values())[0])[:50] if tc["input"] else ""
@@ -1242,9 +1391,24 @@ def run_agent_turn(
1242
1391
  }
1243
1392
  )
1244
1393
  content_blocks.append(tc)
1394
+ if result.startswith("error:"):
1395
+ if result == last_tool_error:
1396
+ repeated_tool_error_count += 1
1397
+ else:
1398
+ last_tool_error = result
1399
+ repeated_tool_error_count = 1
1400
+ if repeated_tool_error_count >= TOOL_ERROR_REPEAT_LIMIT:
1401
+ print(
1402
+ f"{YELLOW}Stopping: repeated identical tool error {repeated_tool_error_count} times.{RESET}"
1403
+ )
1404
+ stop_due_to_repeated_error = True
1405
+ break
1406
+ else:
1407
+ last_tool_error = None
1408
+ repeated_tool_error_count = 0
1245
1409
 
1246
1410
  messages.append({"role": "assistant", "content": content_blocks})
1247
- if not xml_tool_results:
1411
+ if stop_due_to_repeated_error or not xml_tool_results:
1248
1412
  break
1249
1413
  messages.append({"role": "user", "content": xml_tool_results})
1250
1414
 
@@ -1486,6 +1650,29 @@ def load_model() -> Optional[tuple[Any, Any]]:
1486
1650
  if BACKEND == "local":
1487
1651
  print(f"{DIM}Local proxy at {API_BASE}{RESET}\n")
1488
1652
  return None
1653
+ if BACKEND == "ollama":
1654
+ base = os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
1655
+ try:
1656
+ with urllib.request.urlopen(f"{base}/api/tags", timeout=3) as r:
1657
+ installed = {
1658
+ m.get("name", "")
1659
+ for m in json.loads(r.read().decode()).get("models", [])
1660
+ }
1661
+ want = MODEL if ":" in MODEL else f"{MODEL}:latest"
1662
+ if not (
1663
+ MODEL in installed
1664
+ or want in installed
1665
+ or any(n.split(":")[0] == MODEL for n in installed)
1666
+ ):
1667
+ have = ", ".join(sorted(installed)) or "none"
1668
+ print(f"{YELLOW}⚠ Model '{MODEL}' isn't pulled into Ollama.{RESET}")
1669
+ print(f"{DIM} Run: ollama pull {MODEL} (installed: {have}){RESET}")
1670
+ except Exception:
1671
+ print(
1672
+ f"{YELLOW}⚠ Couldn't reach Ollama at {base} — is `ollama serve` running?{RESET}"
1673
+ )
1674
+ print(f"{DIM}{BACKEND} ({MODEL}){RESET}\n")
1675
+ return None
1489
1676
  # Hosted API backends — all require a key.
1490
1677
  if not API_KEY:
1491
1678
  key_env = BACKEND_SPECS[BACKEND]["key_env"]
@@ -1539,6 +1726,7 @@ def main() -> None:
1539
1726
  )
1540
1727
  resolve_configuration(force_chooser=force)
1541
1728
 
1729
+ sys.stdout.write("\033]0;wrencode\007") # set terminal tab/window title
1542
1730
  print(WREN_BANNER)
1543
1731
  print(f"{BOLD}wrencode{RESET} 🐦 | {DIM}{BACKEND}:{MODEL}{RESET}\n")
1544
1732
  mlx_state = load_model()
@@ -1550,7 +1738,7 @@ def main() -> None:
1550
1738
 
1551
1739
  while True:
1552
1740
  try:
1553
- user_input = input(f"{BOLD}{BLUE}❯{RESET} ").strip()
1741
+ user_input = input(f"{BRIGHT_CYAN}❯{RESET} ").strip()
1554
1742
  if not user_input:
1555
1743
  continue
1556
1744
  action = handle_slash_command(user_input, messages, mlx_state)
@@ -1568,8 +1756,17 @@ def main() -> None:
1568
1756
  except EOFError:
1569
1757
  break
1570
1758
  except Exception as err:
1571
- print(f"{RED}Error: {err}{RESET}")
1572
- traceback.print_exc()
1759
+ msg = str(err)
1760
+ print(f"{RED}Error: {msg}{RESET}")
1761
+ if BACKEND == "ollama" and ("not found" in msg.lower() or "404" in msg):
1762
+ print(
1763
+ f"{YELLOW}Model '{MODEL}' isn't pulled. "
1764
+ f"Run: ollama pull {MODEL} (or `ollama list`).{RESET}"
1765
+ )
1766
+ if os.environ.get("WRENCODE_DEBUG"):
1767
+ traceback.print_exc()
1768
+ else:
1769
+ print(f"{DIM}(set WRENCODE_DEBUG=1 for the full traceback){RESET}")
1573
1770
 
1574
1771
 
1575
1772
  if __name__ == "__main__":