wrencode 0.3.1__tar.gz → 0.3.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: wrencode
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: A minimal agent harness for coding: a readable agent loop and the modules it calls
5
5
  Project-URL: Homepage, https://github.com/almostly/wrencode
6
6
  Project-URL: Repository, https://github.com/almostly/wrencode
@@ -752,76 +752,19 @@ What a session looks like, and the keys that drive it.
752
752
  - **Errors.** A failed request is reported as a sentence and a next step
753
753
  (`The API key was rejected. Run /configure to enter a new one.`), with the
754
754
  raw message under it; `WRENCODE_DEBUG=1` prints it in full.
755
- - **Light terminals.** The prose and code tints are chosen for a dark
756
- background. Set `WRENCODE_THEME=light` on a light one (terminals that
757
- export `COLORFGBG` are detected).
758
-
759
- ## Environment Variables
760
-
761
- |Variable |Default |Description |
762
- |-----------------------------|-----------------------|----------------------------------|
763
- |`BACKEND` |chooser/saved config |Override the saved inference backend|
764
- |`MODEL` |backend-dependent |Model path or ID |
765
- |`WRENCODE_CONFIG_DIR` |`~/.wrencode` |Dir for `config.json` (saved backend/key)|
766
- |`WRENCODE_WORKSPACE` |cwd |Root directory for file operations|
767
- |`WRENCODE_HISTORY_FILE` |`<config dir>/history.json`|Conversation history file, without the Postgres store|
768
- |`WRENCODE_DATABASE_URL` |- |Postgres URL for history; unset, embedded PGlite is used|
769
- |`WRENCODE_MIRROR_URL` |- |A second Postgres that receives a copy of every saved session|
770
- |`WRENCODE_PGLITE_START_TIMEOUT`|`60` |Seconds to wait for the embedded PGlite to start|
771
- |`WRENCODE_UNRESTRICTED_PATHS`|`0` |Allow paths outside workspace |
772
- |`WRENCODE_AUTO_APPROVE` |`0` |Skip y/N confirmation for writes/commands (headless; also `--yes`)|
773
- |`WRENCODE_MAX_SUBAGENT_DEPTH`|`2` |Max nested subagent recursion depth (`task` tool)|
774
- |`WRENCODE_MAX_PARALLEL_SUBAGENTS`|`4` |Subagents run at once from one reply; `1` runs them in order|
775
- |`WRENCODE_SANDBOX_TIMEOUT` |`30` |Seconds a `python` tool snippet may run|
776
- |`WRENCODE_SANDBOX_MEMORY_MB` |`256` |Memory a `python` tool snippet may use|
777
- |`MAX_TOKENS` |`8192`, `16000` for Claude|Max tokens per response |
778
- |`WRENCODE_EFFORT` |- |Claude reasoning effort: `low`, `medium`, `high`, `xhigh`, `max`|
779
- |`WRENCODE_SHOW_USAGE` |`1` |Print the usage line after each turn and the cost estimate while typing|
780
- |`WRENCODE_THEME` |auto |`light` or `dark`: picks the prose and code tints (auto reads `COLORFGBG`)|
781
- |`WRENCODE_STREAM` |`1` |Stream replies as they are written (Anthropic and OpenAI-style backends)|
782
- |`WRENCODE_MCP_TIMEOUT` |`120` |Seconds an MCP tool call may take|
783
- |`WRENCODE_WEB_SEARCH` |`1` |Offer Anthropic's web search to Claude (anthropic backend)|
784
- |`WRENCODE_FETCH_MAX_CHARS` |`40000` |Characters of a fetched page returned per call|
785
- |`WRENCODE_FETCH_LOCAL` |unset |`1` lets `fetch` reach loopback, private and link-local addresses|
786
- |`WRENCODE_MCP_CONNECT_TIMEOUT`|`20` |Seconds to connect to an MCP server|
787
- |`WRENCODE_PRICE` |- |Price of the current model, USD per million tokens: `input,output[,cache_read[,cache_write]]`|
788
- |`WRENCODE_HTTP_TIMEOUT` |`600` |Seconds to wait for a model response|
789
- |`WRENCODE_HTTP_RETRIES` |`2` |Retries on HTTP 429/5xx and network errors, with backoff|
790
- |`WRENCODE_CONTEXT_TOKENS` |`128000` |Model context window, for auto-compaction and `synthesize`|
791
- |`WRENCODE_COMPACT_AT` |`0.75` |Compact at this fraction of the window (`0` disables)|
792
- |`MAX_READ_BYTES` |`4MB` |Max file size to read |
793
- |`MAX_READ_LINES` |`800` |Max lines returned per read |
794
- |`GREP_MAX_MATCHES` |`80` |Max grep results |
795
- |`BASH_TIMEOUT` |`120` |Shell command timeout in seconds |
796
- |`MAX_TOOL_OUTPUT_CHARS` |`48000` |Max tool output before truncation |
797
- |`GLOB_SKIP_DIRS` |`.git,node_modules,...`|Directories to skip in glob |
798
- |`OPENROUTER_API_KEY` |- |OpenRouter API key |
799
- |`NANOGPT_API_KEY` |- |NanoGPT API key |
800
- |`OPENAI_API_KEY` |- |OpenAI API key |
801
- |`ANTHROPIC_API_KEY` |- |Anthropic API key |
802
- |`ANTHROPIC_WORKSPACE_ID` |- |Anthropic workspace id (`wrkspc_…`); required for multi-workspace keys|
803
- |`LOCAL_API_KEY` |`local` |Local proxy API key |
804
- |`LOCAL_PORT` |`8082` |Local proxy port |
805
- |`OLLAMA_HOST` |`http://localhost:11434`|Ollama server base URL |
806
- |`OPENAI_COMPATIBLE_BASE_URL` |`http://localhost:8000/v1`|OpenAI-compatible server base URL|
807
- |`OPENAI_COMPATIBLE_API_KEY` |- |Key for that server, if it needs one|
808
-
809
- ## History
810
-
811
- By default the conversation is saved to `~/.wrencode/history.json` and restored on
812
- the next launch (`WRENCODE_HISTORY_FILE` moves it; `/c` clears it).
813
-
814
- With the `history` extra, conversations live in Postgres instead, as sessions per
815
- project: wrencode resumes the project's latest session on launch, `/clear` starts a
816
- new one and keeps the old, `/sessions` lists them, `/resume` picks one to continue (or `/resume <id>`) and
817
- `/search <text>` looks inside all of them (Postgres full-text search).
818
-
819
- ```bash
820
- pip install 'wrencode[history]' # psycopg; Node.js is needed for the embedded engine
821
- ```
822
-
823
- Two engines, both real Postgres:
824
-
755
+ - **Colors.** wrencode draws in [Baseline](https://github.com/xRiskLab/vscode-themes),
756
+ dark or light to match the terminal's background: prose in the editor text
757
+ color, code the way the editor colors it (identifiers blue, keywords red,
758
+ strings, calls and attributes orange, constants magenta, comments muted), the
759
+ accent blue on the assistant's dot, the prompt and the banner. The background
760
+ is read from `COLORFGBG` or asked of the terminal itself (most answer); when
761
+ neither says, the first run asks you once and `/theme light|dark|auto`
762
+ changes the saved answer. Colors are 24-bit where the terminal takes them (`COLORTERM`, or iTerm2,
763
+ WezTerm, Ghostty and VS Code's terminal) and the nearest of 256 elsewhere;
764
+ `WRENCODE_TRUECOLOR=1|0` overrides. `WRENCODE_THEME=light` or `dark` forces one, `ansi` draws with the terminal's
765
+ own colors instead, and a [Zed](https://zed.dev) theme file
766
+ (`~/.config/zed/themes/mine.json#Mine Light`) replaces the palette with its
767
+ own.
825
768
  - **Embedded PGlite** (the default): Postgres compiled to WebAssembly, run by Node.js
826
769
  with a persistent data directory under `~/.wrencode/pglite`. The first launch runs
827
770
  `npm install` there for the pinned `@electric-sql/pglite` packages; after that it
@@ -723,76 +723,19 @@ What a session looks like, and the keys that drive it.
723
723
  - **Errors.** A failed request is reported as a sentence and a next step
724
724
  (`The API key was rejected. Run /configure to enter a new one.`), with the
725
725
  raw message under it; `WRENCODE_DEBUG=1` prints it in full.
726
- - **Light terminals.** The prose and code tints are chosen for a dark
727
- background. Set `WRENCODE_THEME=light` on a light one (terminals that
728
- export `COLORFGBG` are detected).
729
-
730
- ## Environment Variables
731
-
732
- |Variable |Default |Description |
733
- |-----------------------------|-----------------------|----------------------------------|
734
- |`BACKEND` |chooser/saved config |Override the saved inference backend|
735
- |`MODEL` |backend-dependent |Model path or ID |
736
- |`WRENCODE_CONFIG_DIR` |`~/.wrencode` |Dir for `config.json` (saved backend/key)|
737
- |`WRENCODE_WORKSPACE` |cwd |Root directory for file operations|
738
- |`WRENCODE_HISTORY_FILE` |`<config dir>/history.json`|Conversation history file, without the Postgres store|
739
- |`WRENCODE_DATABASE_URL` |- |Postgres URL for history; unset, embedded PGlite is used|
740
- |`WRENCODE_MIRROR_URL` |- |A second Postgres that receives a copy of every saved session|
741
- |`WRENCODE_PGLITE_START_TIMEOUT`|`60` |Seconds to wait for the embedded PGlite to start|
742
- |`WRENCODE_UNRESTRICTED_PATHS`|`0` |Allow paths outside workspace |
743
- |`WRENCODE_AUTO_APPROVE` |`0` |Skip y/N confirmation for writes/commands (headless; also `--yes`)|
744
- |`WRENCODE_MAX_SUBAGENT_DEPTH`|`2` |Max nested subagent recursion depth (`task` tool)|
745
- |`WRENCODE_MAX_PARALLEL_SUBAGENTS`|`4` |Subagents run at once from one reply; `1` runs them in order|
746
- |`WRENCODE_SANDBOX_TIMEOUT` |`30` |Seconds a `python` tool snippet may run|
747
- |`WRENCODE_SANDBOX_MEMORY_MB` |`256` |Memory a `python` tool snippet may use|
748
- |`MAX_TOKENS` |`8192`, `16000` for Claude|Max tokens per response |
749
- |`WRENCODE_EFFORT` |- |Claude reasoning effort: `low`, `medium`, `high`, `xhigh`, `max`|
750
- |`WRENCODE_SHOW_USAGE` |`1` |Print the usage line after each turn and the cost estimate while typing|
751
- |`WRENCODE_THEME` |auto |`light` or `dark`: picks the prose and code tints (auto reads `COLORFGBG`)|
752
- |`WRENCODE_STREAM` |`1` |Stream replies as they are written (Anthropic and OpenAI-style backends)|
753
- |`WRENCODE_MCP_TIMEOUT` |`120` |Seconds an MCP tool call may take|
754
- |`WRENCODE_WEB_SEARCH` |`1` |Offer Anthropic's web search to Claude (anthropic backend)|
755
- |`WRENCODE_FETCH_MAX_CHARS` |`40000` |Characters of a fetched page returned per call|
756
- |`WRENCODE_FETCH_LOCAL` |unset |`1` lets `fetch` reach loopback, private and link-local addresses|
757
- |`WRENCODE_MCP_CONNECT_TIMEOUT`|`20` |Seconds to connect to an MCP server|
758
- |`WRENCODE_PRICE` |- |Price of the current model, USD per million tokens: `input,output[,cache_read[,cache_write]]`|
759
- |`WRENCODE_HTTP_TIMEOUT` |`600` |Seconds to wait for a model response|
760
- |`WRENCODE_HTTP_RETRIES` |`2` |Retries on HTTP 429/5xx and network errors, with backoff|
761
- |`WRENCODE_CONTEXT_TOKENS` |`128000` |Model context window, for auto-compaction and `synthesize`|
762
- |`WRENCODE_COMPACT_AT` |`0.75` |Compact at this fraction of the window (`0` disables)|
763
- |`MAX_READ_BYTES` |`4MB` |Max file size to read |
764
- |`MAX_READ_LINES` |`800` |Max lines returned per read |
765
- |`GREP_MAX_MATCHES` |`80` |Max grep results |
766
- |`BASH_TIMEOUT` |`120` |Shell command timeout in seconds |
767
- |`MAX_TOOL_OUTPUT_CHARS` |`48000` |Max tool output before truncation |
768
- |`GLOB_SKIP_DIRS` |`.git,node_modules,...`|Directories to skip in glob |
769
- |`OPENROUTER_API_KEY` |- |OpenRouter API key |
770
- |`NANOGPT_API_KEY` |- |NanoGPT API key |
771
- |`OPENAI_API_KEY` |- |OpenAI API key |
772
- |`ANTHROPIC_API_KEY` |- |Anthropic API key |
773
- |`ANTHROPIC_WORKSPACE_ID` |- |Anthropic workspace id (`wrkspc_…`); required for multi-workspace keys|
774
- |`LOCAL_API_KEY` |`local` |Local proxy API key |
775
- |`LOCAL_PORT` |`8082` |Local proxy port |
776
- |`OLLAMA_HOST` |`http://localhost:11434`|Ollama server base URL |
777
- |`OPENAI_COMPATIBLE_BASE_URL` |`http://localhost:8000/v1`|OpenAI-compatible server base URL|
778
- |`OPENAI_COMPATIBLE_API_KEY` |- |Key for that server, if it needs one|
779
-
780
- ## History
781
-
782
- By default the conversation is saved to `~/.wrencode/history.json` and restored on
783
- the next launch (`WRENCODE_HISTORY_FILE` moves it; `/c` clears it).
784
-
785
- With the `history` extra, conversations live in Postgres instead, as sessions per
786
- project: wrencode resumes the project's latest session on launch, `/clear` starts a
787
- new one and keeps the old, `/sessions` lists them, `/resume` picks one to continue (or `/resume <id>`) and
788
- `/search <text>` looks inside all of them (Postgres full-text search).
789
-
790
- ```bash
791
- pip install 'wrencode[history]' # psycopg; Node.js is needed for the embedded engine
792
- ```
793
-
794
- Two engines, both real Postgres:
795
-
726
+ - **Colors.** wrencode draws in [Baseline](https://github.com/xRiskLab/vscode-themes),
727
+ dark or light to match the terminal's background: prose in the editor text
728
+ color, code the way the editor colors it (identifiers blue, keywords red,
729
+ strings, calls and attributes orange, constants magenta, comments muted), the
730
+ accent blue on the assistant's dot, the prompt and the banner. The background
731
+ is read from `COLORFGBG` or asked of the terminal itself (most answer); when
732
+ neither says, the first run asks you once and `/theme light|dark|auto`
733
+ changes the saved answer. Colors are 24-bit where the terminal takes them (`COLORTERM`, or iTerm2,
734
+ WezTerm, Ghostty and VS Code's terminal) and the nearest of 256 elsewhere;
735
+ `WRENCODE_TRUECOLOR=1|0` overrides. `WRENCODE_THEME=light` or `dark` forces one, `ansi` draws with the terminal's
736
+ own colors instead, and a [Zed](https://zed.dev) theme file
737
+ (`~/.config/zed/themes/mine.json#Mine Light`) replaces the palette with its
738
+ own.
796
739
  - **Embedded PGlite** (the default): Postgres compiled to WebAssembly, run by Node.js
797
740
  with a persistent data directory under `~/.wrencode/pglite`. The first launch runs
798
741
  `npm install` there for the pinned `@electric-sql/pglite` packages; after that it
@@ -61,7 +61,7 @@ include = ["src/wrencode", "README.md"]
61
61
 
62
62
  [tool.commitizen]
63
63
  name = "cz_conventional_commits"
64
- version = "0.3.1"
64
+ version = "0.3.3"
65
65
  version_scheme = "pep440"
66
66
  tag_format = "$version"
67
67
  legacy_tag_formats = ["v$version"]
@@ -4,4 +4,4 @@
4
4
  with `wrencode` (the installed command) or `python -m wrencode`.
5
5
  """
6
6
 
7
- __version__ = "0.3.1"
7
+ __version__ = "0.3.3"
@@ -2025,6 +2025,9 @@ def handle_slash_command(
2025
2025
  if cmd == "/permissions" or cmd.startswith("/permissions "):
2026
2026
  _permissions_command(cmd)
2027
2027
  return "handled", configure._MLX_UNCHANGED
2028
+ if cmd == "/theme" or cmd.startswith("/theme "):
2029
+ _theme_command(cmd)
2030
+ return "handled", configure._MLX_UNCHANGED
2028
2031
  if cmd == "/mcp" or cmd.startswith("/mcp "):
2029
2032
  _mcp_command(cmd)
2030
2033
  return "handled", configure._MLX_UNCHANGED
@@ -2411,6 +2414,44 @@ def _mcp_command(cmd: str) -> None:
2411
2414
  )
2412
2415
 
2413
2416
 
2417
+ def _setup_theme() -> None:
2418
+ """When nothing says whether the terminal is dark or light, ask once on the
2419
+ first run and keep the answer in the config file (/theme changes it)."""
2420
+ if ui.THEME_SOURCE or not (sys.stdin.isatty() and sys.stdout.isatty()):
2421
+ return
2422
+ answer = ui.ask_line("Is this terminal dark or light? d/l [d] ").strip().lower()
2423
+ theme = "light" if answer.startswith("l") else "dark"
2424
+ ui.apply_theme(theme)
2425
+ backends.save_config({**backends.load_config(), "theme": theme})
2426
+ ui.print_system(f"Using the {theme} colors; /theme changes that.")
2427
+
2428
+
2429
+ def _theme_command(cmd: str) -> None:
2430
+ """/theme shows the colors in use; /theme light|dark|auto switches and saves."""
2431
+ words = cmd.split()
2432
+ choice = words[1].lower() if len(words) > 1 else ""
2433
+ if choice not in ("", "light", "dark", "auto"):
2434
+ ui.print_system(
2435
+ "Usage: /theme · /theme light · /theme dark · /theme auto"
2436
+ )
2437
+ return
2438
+ if choice:
2439
+ ui.apply_theme("" if choice == "auto" else choice)
2440
+ cfg = backends.load_config()
2441
+ if choice == "auto":
2442
+ cfg.pop("theme", None)
2443
+ else:
2444
+ cfg["theme"] = choice
2445
+ backends.save_config(cfg)
2446
+ how = {
2447
+ "env": "from WRENCODE_THEME",
2448
+ "saved": "saved by /theme",
2449
+ "terminal": "read from the terminal",
2450
+ "": "the default; the terminal did not say",
2451
+ }[ui.THEME_SOURCE]
2452
+ ui.print_system(f"Baseline {ui.THEME or 'dark'} ({how}).")
2453
+
2454
+
2414
2455
  def _setup_permissions(interactive: bool) -> None:
2415
2456
  """Load the rules; on a terminal, show a project's allow rules once and ask
2416
2457
  whether to use them, since the repository could have put them there."""
@@ -2700,10 +2741,13 @@ def main() -> None:
2700
2741
  raise SystemExit(run_headless(prompt, fmt, int(turns), schema, verify))
2701
2742
 
2702
2743
  _warn_dotenv_ignored()
2744
+ _setup_theme()
2703
2745
  configure.resolve_configuration()
2704
2746
 
2705
2747
  sys.stdout.write("\033]0;wrencode\007") # set terminal tab/window title
2706
2748
  print(ui.render_banner(ui.colors_enabled()))
2749
+ if ui.THEME_ERROR:
2750
+ print(f"{YELLOW}{ui.visible(ui.THEME_ERROR)}{RESET}")
2707
2751
  mlx_state = backends.load_model()
2708
2752
  _MLX_STATE = mlx_state # expose to the task() subagent tool
2709
2753
  system_prompt = build_system_prompt()
@@ -337,6 +337,27 @@ WEB_SEARCH = os.environ.get("WRENCODE_WEB_SEARCH", "1").lower() not in (
337
337
  "no",
338
338
  )
339
339
  WEB_SEARCH_TOOL: dict[str, Any] = {"type": "web_search_20260209", "name": "web_search"}
340
+ # The models that take the current search tool (with dynamic filtering); the
341
+ # others, Haiku among them, refuse it and take the basic one.
342
+ _WEB_SEARCH_CURRENT = (
343
+ "opus-5",
344
+ "opus-4-8",
345
+ "opus-4-7",
346
+ "opus-4-6",
347
+ "sonnet-5",
348
+ "sonnet-4-6",
349
+ "fable-5",
350
+ )
351
+
352
+
353
+ def web_search_tool(model: str) -> dict[str, Any]:
354
+ """The server-side web search tool definition `model` accepts."""
355
+ m = model.lower().replace(".", "-")
356
+ if any(tag in m for tag in _WEB_SEARCH_CURRENT):
357
+ return dict(WEB_SEARCH_TOOL)
358
+ return {"type": "web_search_20250305", "name": "web_search"}
359
+
360
+
340
361
  WEB_SEARCH_USD = 0.01 # $10 per 1,000 searches
341
362
 
342
363
 
@@ -1884,7 +1905,7 @@ def get_response(
1884
1905
  defs[-1] = {**defs[-1], "cache_control": {"type": "ephemeral"}}
1885
1906
  if WEB_SEARCH and BACKEND == "anthropic" and tools:
1886
1907
  defs = [
1887
- dict(WEB_SEARCH_TOOL),
1908
+ web_search_tool(MODEL),
1888
1909
  *defs,
1889
1910
  ] # first, so the cache marker stays last
1890
1911
  payload = {
@@ -9,11 +9,14 @@ from __future__ import annotations
9
9
 
10
10
  import contextlib
11
11
  import os
12
+ import pathlib
12
13
  import re
13
14
  import select
14
15
  import sys
15
16
  import threading
16
- from collections.abc import Callable, Generator
17
+ import time
18
+ from collections.abc import Callable, Generator, Mapping
19
+ from dataclasses import dataclass
17
20
  from typing import Any
18
21
 
19
22
  from . import permissions
@@ -31,39 +34,407 @@ def _agent_tag() -> str:
31
34
 
32
35
 
33
36
  # -----------------------------------------------------------------------------------------------
34
- # Terminal colors
37
+ # Terminal colors and themes
35
38
  # -----------------------------------------------------------------------------------------------
36
- RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
37
- BLUE, CYAN, GREEN, YELLOW, RED = (
38
- "\033[34m",
39
- "\033[36m",
40
- "\033[32m",
41
- "\033[33m",
42
- "\033[31m",
43
- )
44
- BRIGHT_CYAN = "\033[96m"
39
+ # The plain SGR codes: the terminal's own sixteen colors, which follow whatever
40
+ # theme it has. A theme (WRENCODE_THEME) replaces them with exact colors.
41
+ RESET, BOLD = "\033[0m", "\033[1m"
42
+ _ANSI_DIM, _ANSI_BLUE, _ANSI_CYAN = "\033[2m", "\033[34m", "\033[36m"
43
+ _ANSI_GREEN, _ANSI_YELLOW, _ANSI_RED = "\033[32m", "\033[33m", "\033[31m"
44
+ _ANSI_BRIGHT_CYAN = "\033[96m"
45
+
46
+
47
+ def query_background(timeout: float = 0.25) -> str:
48
+ """Ask the terminal for its background color (OSC 11) and answer "light",
49
+ "dark" or "" when it does not say within `timeout`. Most terminals
50
+ (iTerm2, Terminal.app, Ghostty, kitty, Alacritty, WezTerm, Warp, VS Code
51
+ and Zed's) answer at once; one that doesn't sends nothing back."""
52
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
53
+ return ""
54
+ try:
55
+ import select
56
+ import termios
57
+ import tty
58
+
59
+ fd = sys.stdin.fileno()
60
+ old = termios.tcgetattr(fd)
61
+ except Exception:
62
+ return ""
63
+ reply = b""
64
+ try:
65
+ tty.setcbreak(fd)
66
+ sys.stdout.write("\033]11;?\033\\")
67
+ sys.stdout.flush()
68
+ deadline = time.monotonic() + timeout
69
+ while time.monotonic() < deadline:
70
+ left = deadline - time.monotonic()
71
+ if not select.select([fd], [], [], max(left, 0))[0]:
72
+ break
73
+ reply += os.read(fd, 64)
74
+ if reply.endswith((b"\x07", b"\x1b\\")):
75
+ break
76
+ except Exception:
77
+ return ""
78
+ finally:
79
+ with contextlib.suppress(Exception):
80
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
81
+ m = re.search(rb"11;rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)", reply)
82
+ if not m:
83
+ return ""
84
+ r, g, b = (int(x[:2], 16) for x in m.groups())
85
+ return "light" if 0.299 * r + 0.587 * g + 0.114 * b > 128 else "dark"
86
+
87
+
88
+ def saved_theme() -> str:
89
+ """The theme kept in the config file ("light", "dark"), or "" for none or auto."""
90
+ import json
91
+
92
+ config_dir = pathlib.Path(
93
+ os.environ.get("WRENCODE_CONFIG_DIR", "~/.wrencode")
94
+ ).expanduser()
95
+ try:
96
+ theme = json.loads((config_dir / "config.json").read_text()).get("theme", "")
97
+ except (OSError, ValueError, AttributeError):
98
+ return ""
99
+ return theme if theme in ("light", "dark") else ""
45
100
 
46
101
 
47
- def _light_background() -> bool:
48
- """Whether the terminal is light: WRENCODE_THEME=light|dark wins, then the
49
- COLORFGBG hint some terminals export ("15;0" is white on black)."""
102
+ def detect_theme() -> tuple[str, str]:
103
+ """("light" or "dark", where it came from): WRENCODE_THEME=light|dark
104
+ ("env"), the config file's theme ("saved"), the COLORFGBG hint some
105
+ terminals export, "15;0" being white on black, or the terminal's answer
106
+ to a background query ("terminal"); ("", "") when none of them says."""
50
107
  theme = os.environ.get("WRENCODE_THEME", "").lower()
51
108
  if theme in ("light", "dark"):
52
- return theme == "light"
109
+ return theme, "env"
110
+ if saved := saved_theme():
111
+ return saved, "saved"
53
112
  fgbg = os.environ.get("COLORFGBG", "")
54
113
  if ";" in fgbg:
55
114
  bg = fgbg.rsplit(";", 1)[-1]
56
- return bg.isdigit() and (int(bg) in (7, 15) or int(bg) >= 231)
57
- return False
115
+ if bg.isdigit():
116
+ light = int(bg) in (7, 15) or int(bg) >= 231
117
+ return ("light" if light else "dark"), "terminal"
118
+ if asked := query_background():
119
+ return asked, "terminal"
120
+ return "", ""
121
+
122
+
123
+ def terminal_theme() -> str:
124
+ """ "light", "dark", or "" when nothing says (see detect_theme)."""
125
+ return detect_theme()[0]
126
+
127
+
128
+ def text_colors(theme: str) -> tuple[str, str]:
129
+ """(prose, code) colors for the assistant's replies with the terminal's own
130
+ palette. On a known background the prose is a shade off the user's text and
131
+ code has a tint of its own; on an unknown one both use the terminal's own
132
+ text color, which is readable everywhere, and code is told apart by its
133
+ gutter and highlighting."""
134
+ if theme == "light":
135
+ return "\033[38;5;236m", "\033[38;5;94m"
136
+ if theme == "dark":
137
+ return "\033[38;5;252m", "\033[38;5;223m"
138
+ return "\033[39m", "\033[39m"
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class Palette:
143
+ """Every color wrencode draws with, by role."""
144
+
145
+ text: str # the assistant's prose
146
+ code: str # fenced code
147
+ muted: str # secondary text: results, hints, the loader
148
+ accent: str # the assistant's dot, the loader symbol, the y/n prompt
149
+ red: str
150
+ green: str
151
+ yellow: str
152
+ blue: str # the ❯ prompt, pickers
153
+ cyan: str # inline code, subagent lines
154
+ keyword: str
155
+ string: str
156
+ number: str # numbers and constants (True, None)
157
+ comment: str
158
+ diff_add: str
159
+ diff_del: str
160
+ call: str = "" # a called function, an attribute ("" : the code color)
161
+ name: str = "" # a defined function's name, punctuation ("" : the code color)
162
+ heading: str = "" # a markdown heading ("" : bold text)
163
+ banner_face: str = "" # "" keeps the built-in blue gradient
164
+ banner_shadow: str = ""
165
+
166
+
167
+ def ansi_palette(theme: str) -> Palette:
168
+ """The terminal's own colors, with the prose and code tints for `theme`."""
169
+ text, code = text_colors(theme)
170
+ return Palette(
171
+ text=text,
172
+ code=code,
173
+ muted=_ANSI_DIM,
174
+ accent=_ANSI_BRIGHT_CYAN,
175
+ red=_ANSI_RED,
176
+ green=_ANSI_GREEN,
177
+ yellow=_ANSI_YELLOW,
178
+ blue=_ANSI_BLUE,
179
+ cyan=_ANSI_CYAN,
180
+ keyword=_ANSI_BLUE,
181
+ string=_ANSI_GREEN,
182
+ number=_ANSI_YELLOW,
183
+ comment=_ANSI_DIM,
184
+ diff_add=_ANSI_GREEN,
185
+ diff_del=_ANSI_RED,
186
+ )
58
187
 
59
188
 
60
- LIGHT = _light_background()
61
- # The assistant's prose: a shade off the user's text. Fenced code: a tint of its
62
- # own so code reads apart from prose. Both chosen for the background in use.
63
- AGENT_TEXT = "\033[38;5;236m" if LIGHT else "\033[38;5;252m"
64
- CODE_TEXT = "\033[38;5;94m" if LIGHT else "\033[38;5;223m"
65
- AGENT_MARK = f"{BRIGHT_CYAN}●{RESET}" # opens every assistant reply
66
- TOOL_MARK = f"{GREEN}●{RESET}" # opens every tool call: same dot, its own color
189
+ def supports_truecolor(env: Mapping[str, str] | None = None) -> bool:
190
+ """Whether the terminal takes 24-bit colors: COLORTERM says so, or it is a
191
+ terminal known to (iTerm2, WezTerm, Ghostty, kitty, VS Code's), or
192
+ WRENCODE_TRUECOLOR=1 says it does. Terminal.app and a tmux without
193
+ truecolor configured do not, and get the nearest of 256 colors instead."""
194
+ env = os.environ if env is None else env
195
+ if env.get("WRENCODE_TRUECOLOR", "").lower() in ("1", "true", "yes"):
196
+ return True
197
+ if env.get("WRENCODE_TRUECOLOR", "").lower() in ("0", "false", "no"):
198
+ return False
199
+ if env.get("COLORTERM", "").lower() in ("truecolor", "24bit"):
200
+ return True
201
+ return env.get("TERM_PROGRAM", "") in ("iTerm.app", "WezTerm", "ghostty", "vscode")
202
+
203
+
204
+ TRUECOLOR = supports_truecolor()
205
+
206
+
207
+ def nearest_256(r: int, g: int, b: int) -> int:
208
+ """The index of the closest color in the xterm 256-color table."""
209
+ steps = (0, 95, 135, 175, 215, 255)
210
+
211
+ def near(v: int) -> int:
212
+ return min(range(6), key=lambda i: abs(steps[i] - v))
213
+
214
+ cr, cg, cb = near(r), near(g), near(b)
215
+ cube = 16 + 36 * cr + 6 * cg + cb
216
+ cube_dist = (steps[cr] - r) ** 2 + (steps[cg] - g) ** 2 + (steps[cb] - b) ** 2
217
+ grey_level = max(0, min(23, round((((r + g + b) / 3) - 8) / 10)))
218
+ grey = 8 + 10 * grey_level
219
+ grey_dist = (grey - r) ** 2 + (grey - g) ** 2 + (grey - b) ** 2
220
+ return 232 + grey_level if grey_dist < cube_dist else cube
221
+
222
+
223
+ def truecolor(hex_color: str) -> str:
224
+ """An SGR for a "#rrggbb" or "#rrggbbaa" color (the alpha is ignored):
225
+ 24-bit where the terminal takes it, else the nearest of 256 colors."""
226
+ h = hex_color.strip().lstrip("#")
227
+ if len(h) not in (6, 8) or any(c not in "0123456789abcdefABCDEF" for c in h):
228
+ raise ValueError(f"not a color: {hex_color!r}")
229
+ r, g, b = (int(h[i : i + 2], 16) for i in (0, 2, 4))
230
+ if TRUECOLOR:
231
+ return f"\033[38;2;{r};{g};{b}m"
232
+ return f"\033[38;5;{nearest_256(r, g, b)}m"
233
+
234
+
235
+ def palette_from_zed(style: dict[str, Any]) -> Palette:
236
+ """A palette from the `style` of a Zed theme: its text colors, terminal
237
+ colors and syntax colors. Zed's `text` is the terminal foreground, so the
238
+ prose and code keep it and code is told apart by its highlighting."""
239
+ syntax = style.get("syntax") or {}
240
+
241
+ def color(*keys: str, default: str) -> str:
242
+ for key in keys:
243
+ value = style.get(key)
244
+ if value is None and key.startswith("syntax."):
245
+ entry = syntax.get(key[len("syntax.") :])
246
+ value = entry.get("color") if isinstance(entry, dict) else None
247
+ if isinstance(value, str) and value:
248
+ return truecolor(value)
249
+ return default
250
+
251
+ text = color("text", "terminal.foreground", default="\033[39m")
252
+ muted = color("text.muted", "terminal.dim_foreground", default=_ANSI_DIM)
253
+ return Palette(
254
+ text=text,
255
+ code=text,
256
+ muted=muted,
257
+ accent=color("text.accent", "terminal.ansi.cyan", default=_ANSI_BRIGHT_CYAN),
258
+ red=color("terminal.ansi.red", "error", default=_ANSI_RED),
259
+ green=color("terminal.ansi.green", "success", default=_ANSI_GREEN),
260
+ yellow=color("terminal.ansi.yellow", "warning", default=_ANSI_YELLOW),
261
+ blue=color("terminal.ansi.blue", "info", default=_ANSI_BLUE),
262
+ cyan=color("terminal.ansi.cyan", "hint", default=_ANSI_CYAN),
263
+ keyword=color("syntax.keyword", default=_ANSI_BLUE),
264
+ string=color("syntax.string", default=_ANSI_GREEN),
265
+ number=color("syntax.number", default=_ANSI_YELLOW),
266
+ comment=color("syntax.comment", default=muted),
267
+ diff_add=color(
268
+ "syntax.diff.plus", "version_control.added", default=_ANSI_GREEN
269
+ ),
270
+ diff_del=color(
271
+ "syntax.diff.minus", "version_control.deleted", default=_ANSI_RED
272
+ ),
273
+ banner_face=color("text.accent", "terminal.ansi.blue", default=""),
274
+ banner_shadow=muted,
275
+ )
276
+
277
+
278
+ # Baseline, by Denis Burakov (github.com/xRiskLab/vscode-themes): wrencode's
279
+ # look, dark or light by the terminal's background. Prose is the editor text,
280
+ # code is drawn the way its editor theme draws Python: identifiers blue,
281
+ # keywords red, strings and calls and attributes orange, numbers and constants
282
+ # magenta, comments muted, a defined function's name and punctuation in the
283
+ # text color. The accent is the cursor and badge blue.
284
+ _BASELINE = {
285
+ "accent": "#528bff",
286
+ "red": "#e16d76",
287
+ "green": "#72c45a",
288
+ "yellow": "#d19a66",
289
+ "blue": "#62afef",
290
+ "cyan": "#58b6c2",
291
+ "magenta": "#c678de",
292
+ "comment": "#5c6370",
293
+ }
294
+ _BASELINE_TEXT = {"dark": "#abb2bf", "light": "#383a42"}
295
+
296
+
297
+ def baseline_palette(background: str) -> Palette:
298
+ """The Baseline palette for a dark or light background ("" counts as dark)."""
299
+ c = {k: truecolor(v) for k, v in _BASELINE.items()}
300
+ text = truecolor(_BASELINE_TEXT["light" if background == "light" else "dark"])
301
+ muted = c["comment"]
302
+ return Palette(
303
+ text=text,
304
+ code=c["blue"],
305
+ muted=muted,
306
+ accent=c["accent"],
307
+ red=c["red"],
308
+ green=c["green"],
309
+ yellow=c["yellow"],
310
+ blue=c["blue"],
311
+ cyan=c["cyan"],
312
+ keyword=c["red"],
313
+ string=c["yellow"],
314
+ number=c["magenta"],
315
+ comment=muted,
316
+ diff_add=c["green"],
317
+ diff_del=c["red"],
318
+ call=c["yellow"],
319
+ name=text,
320
+ heading=c["blue"],
321
+ banner_face=c["accent"],
322
+ banner_shadow=muted,
323
+ )
324
+
325
+
326
+ def load_zed_theme(path: str, name: str = "", background: str = "") -> Palette:
327
+ """The palette of a Zed theme file (~/.config/zed/themes/*.json): the theme
328
+ called `name`, else the first one whose appearance matches `background`
329
+ ("dark" when unknown), else the first."""
330
+ import json
331
+
332
+ try:
333
+ data = json.loads(pathlib.Path(path).expanduser().read_text())
334
+ except (OSError, ValueError) as err:
335
+ raise ValueError(f"could not read the theme {path}: {err}") from err
336
+ themes = data.get("themes") if isinstance(data, dict) else None
337
+ if not isinstance(themes, list) or not themes:
338
+ raise ValueError(f"{path} has no themes in it (a Zed theme file has a list)")
339
+ themes = [
340
+ t for t in themes if isinstance(t, dict) and isinstance(t.get("style"), dict)
341
+ ]
342
+ if name:
343
+ chosen = next((t for t in themes if t.get("name") == name), None)
344
+ if chosen is None:
345
+ names = ", ".join(str(t.get("name")) for t in themes)
346
+ raise ValueError(f"{path} has no theme called {name!r} (it has: {names})")
347
+ else:
348
+ wanted = background or "dark"
349
+ chosen = next((t for t in themes if t.get("appearance") == wanted), themes[0])
350
+ return palette_from_zed(chosen["style"])
351
+
352
+
353
+ def resolve_palette(spec: str, background: str) -> tuple[Palette, str]:
354
+ """The palette in use, and an error to show when WRENCODE_THEME couldn't be
355
+ followed (Baseline is used then). `spec` is "" (Baseline, dark or light
356
+ by `background`), "light" or "dark" (Baseline, that variant), "ansi" (the
357
+ terminal's own colors), or a Zed theme file, with "#Name" choosing one
358
+ of its themes."""
359
+ spec = spec.strip()
360
+ low = spec.lower()
361
+ if low in ("", "light", "dark"):
362
+ return baseline_palette(low or background), ""
363
+ if low == "ansi":
364
+ return ansi_palette(background), ""
365
+ path, _, name = spec.partition("#")
366
+ if path.endswith(".json"):
367
+ try:
368
+ return load_zed_theme(path, name.strip(), background), ""
369
+ except ValueError as err:
370
+ return baseline_palette(background), str(err)
371
+ return baseline_palette(background), (
372
+ f"WRENCODE_THEME={spec!r} is not a theme; use light, dark, ansi, or a Zed theme file"
373
+ )
374
+
375
+
376
+ THEME, THEME_SOURCE = detect_theme()
377
+ LIGHT = THEME == "light"
378
+ PALETTE, THEME_ERROR = resolve_palette(os.environ.get("WRENCODE_THEME", ""), THEME)
379
+ DIM = PALETTE.muted
380
+ BLUE, CYAN, GREEN, YELLOW, RED = (
381
+ PALETTE.blue,
382
+ PALETTE.cyan,
383
+ PALETTE.green,
384
+ PALETTE.yellow,
385
+ PALETTE.red,
386
+ )
387
+ BRIGHT_CYAN = PALETTE.accent
388
+ AGENT_TEXT, CODE_TEXT = PALETTE.text, PALETTE.code
389
+
390
+ # The names the other modules import by value; apply_theme rebinds them there too.
391
+ _COLOR_NAMES = (
392
+ "DIM",
393
+ "BLUE",
394
+ "CYAN",
395
+ "GREEN",
396
+ "YELLOW",
397
+ "RED",
398
+ "BRIGHT_CYAN",
399
+ "AGENT_TEXT",
400
+ "CODE_TEXT",
401
+ "AGENT_MARK",
402
+ "TOOL_MARK",
403
+ )
404
+
405
+
406
+ def apply_theme(theme: str, source: str = "saved") -> None:
407
+ """Switch to the Baseline variant for `theme` ("light", "dark", or "" to
408
+ detect again) in this module and in every wrencode module that imported
409
+ the color names, so the change shows at once."""
410
+ global THEME, THEME_SOURCE, LIGHT, PALETTE, THEME_ERROR
411
+ if theme not in ("light", "dark"):
412
+ theme, source = detect_theme()
413
+ THEME, THEME_SOURCE, LIGHT = theme, source, theme == "light"
414
+ PALETTE, THEME_ERROR = resolve_palette(os.environ.get("WRENCODE_THEME", ""), theme)
415
+ values = {
416
+ "DIM": PALETTE.muted,
417
+ "BLUE": PALETTE.blue,
418
+ "CYAN": PALETTE.cyan,
419
+ "GREEN": PALETTE.green,
420
+ "YELLOW": PALETTE.yellow,
421
+ "RED": PALETTE.red,
422
+ "BRIGHT_CYAN": PALETTE.accent,
423
+ "AGENT_TEXT": PALETTE.text,
424
+ "CODE_TEXT": PALETTE.code,
425
+ "AGENT_MARK": f"{PALETTE.accent}●{RESET}",
426
+ "TOOL_MARK": f"{PALETTE.green}●{RESET}",
427
+ }
428
+ this = sys.modules[__name__]
429
+ for module in [this, *sys.modules.values()]:
430
+ if module is this or (module.__name__ or "").startswith("wrencode."):
431
+ for name in _COLOR_NAMES:
432
+ if hasattr(module, name):
433
+ setattr(module, name, values[name])
434
+
435
+
436
+ AGENT_MARK = f"{PALETTE.accent}●{RESET}" # opens every assistant reply
437
+ TOOL_MARK = f"{PALETTE.green}●{RESET}" # opens every tool call: same dot, its own color
67
438
  _COMPOSE_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
68
439
  _LOADER_MODEL_MAX = 32
69
440
 
@@ -226,6 +597,7 @@ SLASH_COMMANDS: dict[str, str] = {
226
597
  "/sync": "copy this project's history to the mirror now",
227
598
  "/usage": "token usage and spend: this turn and the session",
228
599
  "/permissions": "rules that allow or deny actions without asking",
600
+ "/theme": "dark or light colors (or /theme auto)",
229
601
  "/mcp": "MCP servers and their tools (/mcp reload reconnects)",
230
602
  "/help": "list commands",
231
603
  "/quit": "save history and exit",
@@ -757,10 +1129,10 @@ def render_banner(color: bool) -> str:
757
1129
  """Return the startup banner, colored when color is True."""
758
1130
  if not color:
759
1131
  return "\n".join(_BANNER_ROWS)
760
- shade = f"\033[38;5;{_BANNER_SHADOW}m"
1132
+ shade = PALETTE.banner_shadow or f"\033[38;5;{_BANNER_SHADOW}m"
761
1133
  lines = []
762
1134
  for row, face in zip(_BANNER_ROWS, _BANNER_FACES):
763
- tint = f"\033[38;5;{face}m"
1135
+ tint = PALETTE.banner_face or f"\033[38;5;{face}m"
764
1136
  runs = re.sub(
765
1137
  r"█+|[^█ ]+",
766
1138
  lambda m, tint=tint: (tint if m[0][0] == "█" else shade) + m[0],
@@ -955,35 +1327,50 @@ def _confirm_prompt_lines(question: str, action: str, subject: str) -> str:
955
1327
 
956
1328
 
957
1329
  # Lightweight, language-agnostic code coloring — no pygments, keeps the binary lean.
1330
+ _KEYWORDS = (
1331
+ "def|class|return|import|from|if|elif|else|for|while|try|except|finally|with|"
1332
+ "as|in|not|and|or|is|lambda|yield|async|await|pass|break|continue|raise|global|"
1333
+ "nonlocal|assert|del|const|let|var|function|export|default|new|this|fn|match|"
1334
+ "struct|enum|pub|use|mut|public|private|static|void|type|interface|impl|where|"
1335
+ "switch|case|do|typeof|instanceof|throw|catch|extends|implements"
1336
+ )
958
1337
  _CODE_TOKEN = re.compile(
959
1338
  r"(?P<comment>#[^\n]*|//[^\n]*)"
960
- r"|(?P<string>\"[^\"\n]*\"|'[^'\n]*'|`[^`\n]*`)"
961
- r"|(?P<num>\b\d[\d_.]*\b)"
962
- r"|(?P<kw>\b(?:def|class|return|import|from|if|elif|else|for|while|try|except|"
963
- r"finally|with|as|in|not|and|or|is|lambda|yield|async|await|pass|break|continue|"
964
- r"raise|global|nonlocal|assert|True|False|None|const|let|var|function|export|"
965
- r"default|new|this|fn|match|struct|enum|pub|use|mut|public|private|static|void)\b)"
1339
+ r"|(?P<string>\"\"\"[\s\S]*?\"\"\"|\"[^\"\n]*\"|'[^'\n]*'|`[^`\n]*`)"
1340
+ r"|(?P<deco>@[A-Za-z_][\w.]*)"
1341
+ r"|(?P<num>\b(?:\d[\d_.]*|True|False|None|true|false|null|nil)\b)"
1342
+ r"|(?P<defname>(?<=\bdef )[A-Za-z_]\w*)"
1343
+ rf"|(?P<kw>\b(?:{_KEYWORDS})\b)"
1344
+ r"|(?P<call>[A-Za-z_]\w*(?=\())"
1345
+ r"|(?P<attr>(?<=\.)[A-Za-z_]\w*)"
1346
+ r"|(?P<punct>[^\w\s]+)"
966
1347
  )
967
1348
 
968
1349
 
969
1350
  def _highlight_code(code: str, base: str = "") -> str:
970
- """Apply light ANSI syntax coloring to a code block (best-effort, any language).
971
-
972
- `base` is the color the rest of the code is drawn in; it is restored after
973
- every token so the block keeps its tint.
974
- """
1351
+ """Color a code block the way Baseline colors code (best-effort, any
1352
+ language): comments muted, strings and calls and attributes orange,
1353
+ keywords red, numbers, constants and decorators magenta, a defined
1354
+ function's name and punctuation in the text color, and everything else
1355
+ (identifiers) in `base`, the code color, which is restored after every
1356
+ token."""
975
1357
 
976
1358
  def color(m: re.Match[str]) -> str:
977
1359
  g = m.lastgroup
978
- if g == "comment":
979
- return f"{DIM}{m.group()}{RESET}{base}"
980
- if g == "string":
981
- return f"{GREEN}{m.group()}{RESET}{base}"
982
- if g == "num":
983
- return f"{YELLOW}{m.group()}{RESET}{base}"
984
- if g == "kw":
985
- return f"{BLUE}{m.group()}{RESET}{base}"
986
- return m.group()
1360
+ tint = {
1361
+ "comment": PALETTE.comment,
1362
+ "string": PALETTE.string,
1363
+ "deco": PALETTE.number,
1364
+ "num": PALETTE.number,
1365
+ "kw": PALETTE.keyword,
1366
+ "defname": PALETTE.name or base,
1367
+ "call": PALETTE.call or base,
1368
+ "attr": PALETTE.call or base,
1369
+ "punct": PALETTE.name or base,
1370
+ }.get(g or "", "")
1371
+ if not tint:
1372
+ return m.group()
1373
+ return f"{tint}{m.group()}{RESET}{base}"
987
1374
 
988
1375
  return _CODE_TOKEN.sub(color, code)
989
1376
 
@@ -1004,9 +1391,9 @@ def print_diff(label: str, before: str, after: str, limit: int = 40) -> None:
1004
1391
  m = re.match(r"@@ -(\d+)", line)
1005
1392
  out.append(f"{DIM}@@ {visible(label)}:{m.group(1) if m else '?'}{RESET}")
1006
1393
  elif line.startswith("+"):
1007
- out.append(f"{GREEN}{shown}{RESET}")
1394
+ out.append(f"{PALETTE.diff_add}{shown}{RESET}")
1008
1395
  elif line.startswith("-"):
1009
- out.append(f"{RED}{shown}{RESET}")
1396
+ out.append(f"{PALETTE.diff_del}{shown}{RESET}")
1010
1397
  else:
1011
1398
  out.append(f"{DIM}{shown}{RESET}")
1012
1399
  for line in out[:limit]:
@@ -1033,7 +1420,12 @@ def render_markdown(text: str) -> str:
1033
1420
  text = re.sub(r"```(\w*)\n?(.*?)```", stash, text, flags=re.DOTALL)
1034
1421
  text = re.sub(r"`([^`\n]+)`", f"{CYAN}\\1{RESET}", text)
1035
1422
  text = re.sub(r"\*\*(.+?)\*\*", f"{BOLD}\\1{RESET}", text)
1036
- text = re.sub(r"^#{1,6} +(.+)$", f"{BOLD}\\1{RESET}", text, flags=re.MULTILINE)
1423
+ text = re.sub(
1424
+ r"^#{1,6} +(.+)$",
1425
+ f"{PALETTE.heading}{BOLD}\\1{RESET}",
1426
+ text,
1427
+ flags=re.MULTILINE,
1428
+ )
1037
1429
  for i, b in enumerate(blocks):
1038
1430
  text = text.replace(f"\x00B{i}\x00", b)
1039
1431
  return re.sub(r"\n{3,}", "\n\n", text) # one blank line around a block, not two
File without changes
File without changes
File without changes
File without changes