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.
- {wrencode-0.3.1 → wrencode-0.3.2}/PKG-INFO +12 -71
- {wrencode-0.3.1 → wrencode-0.3.2}/README.md +11 -70
- {wrencode-0.3.1 → wrencode-0.3.2}/pyproject.toml +1 -1
- {wrencode-0.3.1 → wrencode-0.3.2}/src/wrencode/__init__.py +1 -1
- {wrencode-0.3.1 → wrencode-0.3.2}/src/wrencode/app.py +44 -0
- {wrencode-0.3.1 → wrencode-0.3.2}/src/wrencode/ui.py +403 -48
- {wrencode-0.3.1 → wrencode-0.3.2}/.gitignore +0 -0
- {wrencode-0.3.1 → wrencode-0.3.2}/src/wrencode/__main__.py +0 -0
- {wrencode-0.3.1 → wrencode-0.3.2}/src/wrencode/backends.py +0 -0
- {wrencode-0.3.1 → wrencode-0.3.2}/src/wrencode/configure.py +0 -0
- {wrencode-0.3.1 → wrencode-0.3.2}/src/wrencode/history.py +0 -0
- {wrencode-0.3.1 → wrencode-0.3.2}/src/wrencode/mcp.py +0 -0
- {wrencode-0.3.1 → wrencode-0.3.2}/src/wrencode/permissions.py +0 -0
- {wrencode-0.3.1 → wrencode-0.3.2}/src/wrencode/sandbox.py +0 -0
- {wrencode-0.3.1 → wrencode-0.3.2}/src/wrencode/sdk.py +0 -0
- {wrencode-0.3.1 → wrencode-0.3.2}/src/wrencode/synthesize.py +0 -0
- {wrencode-0.3.1 → wrencode-0.3.2}/src/wrencode/web.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: wrencode
|
|
3
|
-
Version: 0.3.
|
|
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
|
-
- **
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
- **
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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<
|
|
962
|
-
r"|(?P<
|
|
963
|
-
r"
|
|
964
|
-
|
|
965
|
-
r"
|
|
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
|
-
"""
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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"{
|
|
1357
|
+
out.append(f"{PALETTE.diff_add}{shown}{RESET}")
|
|
1008
1358
|
elif line.startswith("-"):
|
|
1009
|
-
out.append(f"{
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|