wrencode 0.3.1__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: wrencode
3
- Version: 0.3.1
3
+ Version: 0.3.2
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,17 @@ 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. `WRENCODE_THEME=light` or `dark` forces one, `ansi` draws with the terminal's
763
+ own colors instead, and a [Zed](https://zed.dev) theme file
764
+ (`~/.config/zed/themes/mine.json#Mine Light`) replaces the palette with its
765
+ own.
825
766
  - **Embedded PGlite** (the default): Postgres compiled to WebAssembly, run by Node.js
826
767
  with a persistent data directory under `~/.wrencode/pglite`. The first launch runs
827
768
  `npm install` there for the pinned `@electric-sql/pglite` packages; after that it
@@ -723,76 +723,17 @@ 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. `WRENCODE_THEME=light` or `dark` forces one, `ansi` draws with the terminal's
734
+ own colors instead, and a [Zed](https://zed.dev) theme file
735
+ (`~/.config/zed/themes/mine.json#Mine Light`) replaces the palette with its
736
+ own.
796
737
  - **Embedded PGlite** (the default): Postgres compiled to WebAssembly, run by Node.js
797
738
  with a persistent data directory under `~/.wrencode/pglite`. The first launch runs
798
739
  `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.2"
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.2"
@@ -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()
@@ -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
17
+ import time
16
18
  from collections.abc import Callable, Generator
19
+ from dataclasses import dataclass
17
20
  from typing import Any
18
21
 
19
22
  from . import permissions
@@ -31,39 +34,370 @@ 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
45
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
46
91
 
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)."""
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 ""
100
+
101
+
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 truecolor(hex_color: str) -> str:
190
+ """An SGR for a "#rrggbb" or "#rrggbbaa" color (the alpha is ignored)."""
191
+ h = hex_color.strip().lstrip("#")
192
+ if len(h) not in (6, 8) or any(c not in "0123456789abcdefABCDEF" for c in h):
193
+ raise ValueError(f"not a color: {hex_color!r}")
194
+ r, g, b = (int(h[i : i + 2], 16) for i in (0, 2, 4))
195
+ return f"\033[38;2;{r};{g};{b}m"
196
+
197
+
198
+ def palette_from_zed(style: dict[str, Any]) -> Palette:
199
+ """A palette from the `style` of a Zed theme: its text colors, terminal
200
+ colors and syntax colors. Zed's `text` is the terminal foreground, so the
201
+ prose and code keep it and code is told apart by its highlighting."""
202
+ syntax = style.get("syntax") or {}
203
+
204
+ def color(*keys: str, default: str) -> str:
205
+ for key in keys:
206
+ value = style.get(key)
207
+ if value is None and key.startswith("syntax."):
208
+ entry = syntax.get(key[len("syntax.") :])
209
+ value = entry.get("color") if isinstance(entry, dict) else None
210
+ if isinstance(value, str) and value:
211
+ return truecolor(value)
212
+ return default
213
+
214
+ text = color("text", "terminal.foreground", default="\033[39m")
215
+ muted = color("text.muted", "terminal.dim_foreground", default=_ANSI_DIM)
216
+ return Palette(
217
+ text=text,
218
+ code=text,
219
+ muted=muted,
220
+ accent=color("text.accent", "terminal.ansi.cyan", default=_ANSI_BRIGHT_CYAN),
221
+ red=color("terminal.ansi.red", "error", default=_ANSI_RED),
222
+ green=color("terminal.ansi.green", "success", default=_ANSI_GREEN),
223
+ yellow=color("terminal.ansi.yellow", "warning", default=_ANSI_YELLOW),
224
+ blue=color("terminal.ansi.blue", "info", default=_ANSI_BLUE),
225
+ cyan=color("terminal.ansi.cyan", "hint", default=_ANSI_CYAN),
226
+ keyword=color("syntax.keyword", default=_ANSI_BLUE),
227
+ string=color("syntax.string", default=_ANSI_GREEN),
228
+ number=color("syntax.number", default=_ANSI_YELLOW),
229
+ comment=color("syntax.comment", default=muted),
230
+ diff_add=color(
231
+ "syntax.diff.plus", "version_control.added", default=_ANSI_GREEN
232
+ ),
233
+ diff_del=color(
234
+ "syntax.diff.minus", "version_control.deleted", default=_ANSI_RED
235
+ ),
236
+ banner_face=color("text.accent", "terminal.ansi.blue", default=""),
237
+ banner_shadow=muted,
238
+ )
239
+
240
+
241
+ # Baseline, by Denis Burakov (github.com/xRiskLab/vscode-themes): wrencode's
242
+ # look, dark or light by the terminal's background. Prose is the editor text,
243
+ # code is drawn the way its editor theme draws Python: identifiers blue,
244
+ # keywords red, strings and calls and attributes orange, numbers and constants
245
+ # magenta, comments muted, a defined function's name and punctuation in the
246
+ # text color. The accent is the cursor and badge blue.
247
+ _BASELINE = {
248
+ "accent": "#528bff",
249
+ "red": "#e16d76",
250
+ "green": "#72c45a",
251
+ "yellow": "#d19a66",
252
+ "blue": "#62afef",
253
+ "cyan": "#58b6c2",
254
+ "magenta": "#c678de",
255
+ "comment": "#5c6370",
256
+ }
257
+ _BASELINE_TEXT = {"dark": "#abb2bf", "light": "#383a42"}
258
+
259
+
260
+ def baseline_palette(background: str) -> Palette:
261
+ """The Baseline palette for a dark or light background ("" counts as dark)."""
262
+ c = {k: truecolor(v) for k, v in _BASELINE.items()}
263
+ text = truecolor(_BASELINE_TEXT["light" if background == "light" else "dark"])
264
+ muted = c["comment"]
265
+ return Palette(
266
+ text=text,
267
+ code=c["blue"],
268
+ muted=muted,
269
+ accent=c["accent"],
270
+ red=c["red"],
271
+ green=c["green"],
272
+ yellow=c["yellow"],
273
+ blue=c["blue"],
274
+ cyan=c["cyan"],
275
+ keyword=c["red"],
276
+ string=c["yellow"],
277
+ number=c["magenta"],
278
+ comment=muted,
279
+ diff_add=c["green"],
280
+ diff_del=c["red"],
281
+ call=c["yellow"],
282
+ name=text,
283
+ heading=c["blue"],
284
+ banner_face=c["accent"],
285
+ banner_shadow=muted,
286
+ )
287
+
288
+
289
+ def load_zed_theme(path: str, name: str = "", background: str = "") -> Palette:
290
+ """The palette of a Zed theme file (~/.config/zed/themes/*.json): the theme
291
+ called `name`, else the first one whose appearance matches `background`
292
+ ("dark" when unknown), else the first."""
293
+ import json
294
+
295
+ try:
296
+ data = json.loads(pathlib.Path(path).expanduser().read_text())
297
+ except (OSError, ValueError) as err:
298
+ raise ValueError(f"could not read the theme {path}: {err}") from err
299
+ themes = data.get("themes") if isinstance(data, dict) else None
300
+ if not isinstance(themes, list) or not themes:
301
+ raise ValueError(f"{path} has no themes in it (a Zed theme file has a list)")
302
+ themes = [
303
+ t for t in themes if isinstance(t, dict) and isinstance(t.get("style"), dict)
304
+ ]
305
+ if name:
306
+ chosen = next((t for t in themes if t.get("name") == name), None)
307
+ if chosen is None:
308
+ names = ", ".join(str(t.get("name")) for t in themes)
309
+ raise ValueError(f"{path} has no theme called {name!r} (it has: {names})")
310
+ else:
311
+ wanted = background or "dark"
312
+ chosen = next((t for t in themes if t.get("appearance") == wanted), themes[0])
313
+ return palette_from_zed(chosen["style"])
314
+
315
+
316
+ def resolve_palette(spec: str, background: str) -> tuple[Palette, str]:
317
+ """The palette in use, and an error to show when WRENCODE_THEME couldn't be
318
+ followed (Baseline is used then). `spec` is "" (Baseline, dark or light
319
+ by `background`), "light" or "dark" (Baseline, that variant), "ansi" (the
320
+ terminal's own colors), or a Zed theme file, with "#Name" choosing one
321
+ of its themes."""
322
+ spec = spec.strip()
323
+ low = spec.lower()
324
+ if low in ("", "light", "dark"):
325
+ return baseline_palette(low or background), ""
326
+ if low == "ansi":
327
+ return ansi_palette(background), ""
328
+ path, _, name = spec.partition("#")
329
+ if path.endswith(".json"):
330
+ try:
331
+ return load_zed_theme(path, name.strip(), background), ""
332
+ except ValueError as err:
333
+ return baseline_palette(background), str(err)
334
+ return baseline_palette(background), (
335
+ f"WRENCODE_THEME={spec!r} is not a theme; use light, dark, ansi, or a Zed theme file"
336
+ )
337
+
338
+
339
+ THEME, THEME_SOURCE = detect_theme()
340
+ LIGHT = THEME == "light"
341
+ PALETTE, THEME_ERROR = resolve_palette(os.environ.get("WRENCODE_THEME", ""), THEME)
342
+ DIM = PALETTE.muted
343
+ BLUE, CYAN, GREEN, YELLOW, RED = (
344
+ PALETTE.blue,
345
+ PALETTE.cyan,
346
+ PALETTE.green,
347
+ PALETTE.yellow,
348
+ PALETTE.red,
349
+ )
350
+ BRIGHT_CYAN = PALETTE.accent
351
+ AGENT_TEXT, CODE_TEXT = PALETTE.text, PALETTE.code
352
+
353
+ # The names the other modules import by value; apply_theme rebinds them there too.
354
+ _COLOR_NAMES = (
355
+ "DIM",
356
+ "BLUE",
357
+ "CYAN",
358
+ "GREEN",
359
+ "YELLOW",
360
+ "RED",
361
+ "BRIGHT_CYAN",
362
+ "AGENT_TEXT",
363
+ "CODE_TEXT",
364
+ "AGENT_MARK",
365
+ "TOOL_MARK",
366
+ )
367
+
368
+
369
+ def apply_theme(theme: str, source: str = "saved") -> None:
370
+ """Switch to the Baseline variant for `theme` ("light", "dark", or "" to
371
+ detect again) in this module and in every wrencode module that imported
372
+ the color names, so the change shows at once."""
373
+ global THEME, THEME_SOURCE, LIGHT, PALETTE, THEME_ERROR
374
+ if theme not in ("light", "dark"):
375
+ theme, source = detect_theme()
376
+ THEME, THEME_SOURCE, LIGHT = theme, source, theme == "light"
377
+ PALETTE, THEME_ERROR = resolve_palette(os.environ.get("WRENCODE_THEME", ""), theme)
378
+ values = {
379
+ "DIM": PALETTE.muted,
380
+ "BLUE": PALETTE.blue,
381
+ "CYAN": PALETTE.cyan,
382
+ "GREEN": PALETTE.green,
383
+ "YELLOW": PALETTE.yellow,
384
+ "RED": PALETTE.red,
385
+ "BRIGHT_CYAN": PALETTE.accent,
386
+ "AGENT_TEXT": PALETTE.text,
387
+ "CODE_TEXT": PALETTE.code,
388
+ "AGENT_MARK": f"{PALETTE.accent}●{RESET}",
389
+ "TOOL_MARK": f"{PALETTE.green}●{RESET}",
390
+ }
391
+ this = sys.modules[__name__]
392
+ for module in [this, *sys.modules.values()]:
393
+ if module is this or (module.__name__ or "").startswith("wrencode."):
394
+ for name in _COLOR_NAMES:
395
+ if hasattr(module, name):
396
+ setattr(module, name, values[name])
397
+
398
+
399
+ AGENT_MARK = f"{PALETTE.accent}●{RESET}" # opens every assistant reply
400
+ TOOL_MARK = f"{PALETTE.green}●{RESET}" # opens every tool call: same dot, its own color
67
401
  _COMPOSE_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
68
402
  _LOADER_MODEL_MAX = 32
69
403
 
@@ -226,6 +560,7 @@ SLASH_COMMANDS: dict[str, str] = {
226
560
  "/sync": "copy this project's history to the mirror now",
227
561
  "/usage": "token usage and spend: this turn and the session",
228
562
  "/permissions": "rules that allow or deny actions without asking",
563
+ "/theme": "dark or light colors (or /theme auto)",
229
564
  "/mcp": "MCP servers and their tools (/mcp reload reconnects)",
230
565
  "/help": "list commands",
231
566
  "/quit": "save history and exit",
@@ -757,10 +1092,10 @@ def render_banner(color: bool) -> str:
757
1092
  """Return the startup banner, colored when color is True."""
758
1093
  if not color:
759
1094
  return "\n".join(_BANNER_ROWS)
760
- shade = f"\033[38;5;{_BANNER_SHADOW}m"
1095
+ shade = PALETTE.banner_shadow or f"\033[38;5;{_BANNER_SHADOW}m"
761
1096
  lines = []
762
1097
  for row, face in zip(_BANNER_ROWS, _BANNER_FACES):
763
- tint = f"\033[38;5;{face}m"
1098
+ tint = PALETTE.banner_face or f"\033[38;5;{face}m"
764
1099
  runs = re.sub(
765
1100
  r"█+|[^█ ]+",
766
1101
  lambda m, tint=tint: (tint if m[0][0] == "█" else shade) + m[0],
@@ -955,35 +1290,50 @@ def _confirm_prompt_lines(question: str, action: str, subject: str) -> str:
955
1290
 
956
1291
 
957
1292
  # Lightweight, language-agnostic code coloring — no pygments, keeps the binary lean.
1293
+ _KEYWORDS = (
1294
+ "def|class|return|import|from|if|elif|else|for|while|try|except|finally|with|"
1295
+ "as|in|not|and|or|is|lambda|yield|async|await|pass|break|continue|raise|global|"
1296
+ "nonlocal|assert|del|const|let|var|function|export|default|new|this|fn|match|"
1297
+ "struct|enum|pub|use|mut|public|private|static|void|type|interface|impl|where|"
1298
+ "switch|case|do|typeof|instanceof|throw|catch|extends|implements"
1299
+ )
958
1300
  _CODE_TOKEN = re.compile(
959
1301
  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)"
1302
+ r"|(?P<string>\"\"\"[\s\S]*?\"\"\"|\"[^\"\n]*\"|'[^'\n]*'|`[^`\n]*`)"
1303
+ r"|(?P<deco>@[A-Za-z_][\w.]*)"
1304
+ r"|(?P<num>\b(?:\d[\d_.]*|True|False|None|true|false|null|nil)\b)"
1305
+ r"|(?P<defname>(?<=\bdef )[A-Za-z_]\w*)"
1306
+ rf"|(?P<kw>\b(?:{_KEYWORDS})\b)"
1307
+ r"|(?P<call>[A-Za-z_]\w*(?=\())"
1308
+ r"|(?P<attr>(?<=\.)[A-Za-z_]\w*)"
1309
+ r"|(?P<punct>[^\w\s]+)"
966
1310
  )
967
1311
 
968
1312
 
969
1313
  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
- """
1314
+ """Color a code block the way Baseline colors code (best-effort, any
1315
+ language): comments muted, strings and calls and attributes orange,
1316
+ keywords red, numbers, constants and decorators magenta, a defined
1317
+ function's name and punctuation in the text color, and everything else
1318
+ (identifiers) in `base`, the code color, which is restored after every
1319
+ token."""
975
1320
 
976
1321
  def color(m: re.Match[str]) -> str:
977
1322
  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()
1323
+ tint = {
1324
+ "comment": PALETTE.comment,
1325
+ "string": PALETTE.string,
1326
+ "deco": PALETTE.number,
1327
+ "num": PALETTE.number,
1328
+ "kw": PALETTE.keyword,
1329
+ "defname": PALETTE.name or base,
1330
+ "call": PALETTE.call or base,
1331
+ "attr": PALETTE.call or base,
1332
+ "punct": PALETTE.name or base,
1333
+ }.get(g or "", "")
1334
+ if not tint:
1335
+ return m.group()
1336
+ return f"{tint}{m.group()}{RESET}{base}"
987
1337
 
988
1338
  return _CODE_TOKEN.sub(color, code)
989
1339
 
@@ -1004,9 +1354,9 @@ def print_diff(label: str, before: str, after: str, limit: int = 40) -> None:
1004
1354
  m = re.match(r"@@ -(\d+)", line)
1005
1355
  out.append(f"{DIM}@@ {visible(label)}:{m.group(1) if m else '?'}{RESET}")
1006
1356
  elif line.startswith("+"):
1007
- out.append(f"{GREEN}{shown}{RESET}")
1357
+ out.append(f"{PALETTE.diff_add}{shown}{RESET}")
1008
1358
  elif line.startswith("-"):
1009
- out.append(f"{RED}{shown}{RESET}")
1359
+ out.append(f"{PALETTE.diff_del}{shown}{RESET}")
1010
1360
  else:
1011
1361
  out.append(f"{DIM}{shown}{RESET}")
1012
1362
  for line in out[:limit]:
@@ -1033,7 +1383,12 @@ def render_markdown(text: str) -> str:
1033
1383
  text = re.sub(r"```(\w*)\n?(.*?)```", stash, text, flags=re.DOTALL)
1034
1384
  text = re.sub(r"`([^`\n]+)`", f"{CYAN}\\1{RESET}", text)
1035
1385
  text = re.sub(r"\*\*(.+?)\*\*", f"{BOLD}\\1{RESET}", text)
1036
- text = re.sub(r"^#{1,6} +(.+)$", f"{BOLD}\\1{RESET}", text, flags=re.MULTILINE)
1386
+ text = re.sub(
1387
+ r"^#{1,6} +(.+)$",
1388
+ f"{PALETTE.heading}{BOLD}\\1{RESET}",
1389
+ text,
1390
+ flags=re.MULTILINE,
1391
+ )
1037
1392
  for i, b in enumerate(blocks):
1038
1393
  text = text.replace(f"\x00B{i}\x00", b)
1039
1394
  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