detached dfd2501

Add JSON theming, theme-only extensions, and docs

- User themes from $ZOT_HOME/themes/*.json with partial overrides
  (colors, syntax, spinner) and dark/light fallback.
- /settings color-theme picker; selection persisted in config.json.
- Theme-only extensions: extension.json plus theme.json (or
  themes/theme.json) load without spawning a subprocess.
- write-zot-themes built-in skill and docs/themes.md.
- README, extensions docs, and embedded docs index updated.
commit
dfd25012b69e11a30cee5092d8c4ec6ed58d4e0a
author
patriceckhart · · 2026-05-30 09:34 UTC
parent
ecb3b02
signature
unsigned
refs
tag: v0.2.4
23 files · +1923 -188
M README.md +7 -0
@@ -17,6 +17,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
1717 - three run modes (interactive tui, print, json).
1818 - built-in telegram bot.
1919 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zot ext install` or `zot --ext`. See [docs/extensions.md](docs/extensions.md).
20+- user and extension themes via JSON; see [docs/themes.md](docs/themes.md).
2021 - reusable instructions via `SKILL.md` files; see [docs/skills.md](docs/skills.md).
2122 - no community atm.
2223
@@ -108,6 +109,7 @@ $ZOT_HOME/
108109 ├── models-cache.json # live /v1/models discovery cache (6h ttl)
109110 ├── SYSTEM.md # optional: replaces the default system prompt
110111 ├── skills/ # optional: user SKILL.md files
112+├── themes/ # optional: user theme JSON files
111113 ├── extensions/ # installed extensions, one dir per extension
112114 └── logs/ # app log files
113115 ```
@@ -295,6 +297,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
295297 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
296298 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
297299 - **thinking level** — choose reasoning for supported models: off (default; no reasoning), minimum (~1k tokens), low (~2k), medium (~8k), high (~16k), maximum (~32k). The change is persisted to `config.json` and applied to the running agent's next model call.
300+- **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md).
298301
299302 ### `/skills`
300303
@@ -608,6 +611,10 @@ For development, point `zot --ext <path>` at a working directory and skip the in
608611 - Diverged branches, offline pulls, or any other git failure are reported as `failed` and the next extension is processed. `zot update` itself never aborts because of an extension.
609612 - zot does **not** run any build step (`go build`, `npm install`, `make`) after the pull. Extension authors are expected to commit the runnable artifact (binary, transpiled JS, etc.). If you need a build, rebuild manually and use `/reload-ext`.
610613
614+### Theme-only extensions
615+
616+An extension may ship only a theme: `extension.json` plus `theme.json` (or `themes/theme.json`) and no executable. zot loads it without spawning a subprocess and shows it in `/settings` with source information. See [docs/themes.md](docs/themes.md).
617+
611618 ### Reference
612619
613620 `examples/extensions/` ships reference implementations in Go, TypeScript, Node, and shell. See [docs/extensions.md](docs/extensions.md) for the full protocol, the SDK API (`packages/agent/ext`), and the phase roadmap.
@@ -17,6 +17,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
17 - three run modes (interactive tui, print, json).17 - three run modes (interactive tui, print, json).
18 - built-in telegram bot.18 - built-in telegram bot.
19 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zot ext install` or `zot --ext`. See [docs/extensions.md](docs/extensions.md).19 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zot ext install` or `zot --ext`. See [docs/extensions.md](docs/extensions.md).
20+- user and extension themes via JSON; see [docs/themes.md](docs/themes.md).
20 - reusable instructions via `SKILL.md` files; see [docs/skills.md](docs/skills.md).21 - reusable instructions via `SKILL.md` files; see [docs/skills.md](docs/skills.md).
21 - no community atm.22 - no community atm.
22 23
@@ -108,6 +109,7 @@ $ZOT_HOME/
108 ├── models-cache.json # live /v1/models discovery cache (6h ttl)109 ├── models-cache.json # live /v1/models discovery cache (6h ttl)
109 ├── SYSTEM.md # optional: replaces the default system prompt110 ├── SYSTEM.md # optional: replaces the default system prompt
110 ├── skills/ # optional: user SKILL.md files111 ├── skills/ # optional: user SKILL.md files
112+├── themes/ # optional: user theme JSON files
111 ├── extensions/ # installed extensions, one dir per extension113 ├── extensions/ # installed extensions, one dir per extension
112 └── logs/ # app log files114 └── logs/ # app log files
113 ```115 ```
@@ -295,6 +297,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
295 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.297 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
296 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.298 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
297 - **thinking level** — choose reasoning for supported models: off (default; no reasoning), minimum (~1k tokens), low (~2k), medium (~8k), high (~16k), maximum (~32k). The change is persisted to `config.json` and applied to the running agent's next model call.299 - **thinking level** — choose reasoning for supported models: off (default; no reasoning), minimum (~1k tokens), low (~2k), medium (~8k), high (~16k), maximum (~32k). The change is persisted to `config.json` and applied to the running agent's next model call.
300+- **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md).
298 301
299 ### `/skills`302 ### `/skills`
300 303
@@ -608,6 +611,10 @@ For development, point `zot --ext <path>` at a working directory and skip the in
608 - Diverged branches, offline pulls, or any other git failure are reported as `failed` and the next extension is processed. `zot update` itself never aborts because of an extension.611 - Diverged branches, offline pulls, or any other git failure are reported as `failed` and the next extension is processed. `zot update` itself never aborts because of an extension.
609 - zot does **not** run any build step (`go build`, `npm install`, `make`) after the pull. Extension authors are expected to commit the runnable artifact (binary, transpiled JS, etc.). If you need a build, rebuild manually and use `/reload-ext`.612 - zot does **not** run any build step (`go build`, `npm install`, `make`) after the pull. Extension authors are expected to commit the runnable artifact (binary, transpiled JS, etc.). If you need a build, rebuild manually and use `/reload-ext`.
610 613
614+### Theme-only extensions
615+
616+An extension may ship only a theme: `extension.json` plus `theme.json` (or `themes/theme.json`) and no executable. zot loads it without spawning a subprocess and shows it in `/settings` with source information. See [docs/themes.md](docs/themes.md).
617+
611 ### Reference618 ### Reference
612 619
613 `examples/extensions/` ships reference implementations in Go, TypeScript, Node, and shell. See [docs/extensions.md](docs/extensions.md) for the full protocol, the SDK API (`packages/agent/ext`), and the phase roadmap.620 `examples/extensions/` ships reference implementations in Go, TypeScript, Node, and shell. See [docs/extensions.md](docs/extensions.md) for the full protocol, the SDK API (`packages/agent/ext`), and the phase roadmap.
M docs.go +1 -0
@@ -20,6 +20,7 @@ var docFiles = map[string]string{
2020 "extensions.md": "docs/extensions.md",
2121 "rpc.md": "docs/rpc.md",
2222 "skills.md": "docs/skills.md",
23+ "themes.md": "docs/themes.md",
2324 }
2425
2526 // EnsureInstalled writes the embedded docs into $ZOT_HOME/docs and
@@ -20,6 +20,7 @@ var docFiles = map[string]string{
20 "extensions.md": "docs/extensions.md",20 "extensions.md": "docs/extensions.md",
21 "rpc.md": "docs/rpc.md",21 "rpc.md": "docs/rpc.md",
22 "skills.md": "docs/skills.md",22 "skills.md": "docs/skills.md",
23+ "themes.md": "docs/themes.md",
23 }24 }
24 25
25 // EnsureInstalled writes the embedded docs into $ZOT_HOME/docs and26 // EnsureInstalled writes the embedded docs into $ZOT_HOME/docs and
M docs/extensions.md +6 -0
@@ -13,6 +13,8 @@ Four phases shipped so far:
1313 - **Phase 3**: lifecycle event subscriptions + tool-call interception
1414 for guardrail extensions.
1515 - **Phase 4**: interactive extension-owned panels rendered inside zot.
16+- **Theme-only extensions**: ship `theme.json` without launching a
17+ subprocess. See [themes.md](themes.md).
1618
1719 ## Quick start
1820
@@ -54,6 +56,10 @@ Drop it in a directory with this `extension.json`:
5456 }
5557 ```
5658
59+`exec` is required for protocol extensions. If an extension only ships
60+`theme.json` or `themes/theme.json`, no `exec` is required and zot does
61+not spawn a subprocess.
62+
5763 `chmod +x hello.py`, install:
5864
5965 ```bash
@@ -13,6 +13,8 @@ Four phases shipped so far:
13 - **Phase 3**: lifecycle event subscriptions + tool-call interception13 - **Phase 3**: lifecycle event subscriptions + tool-call interception
14 for guardrail extensions.14 for guardrail extensions.
15 - **Phase 4**: interactive extension-owned panels rendered inside zot.15 - **Phase 4**: interactive extension-owned panels rendered inside zot.
16+- **Theme-only extensions**: ship `theme.json` without launching a
17+ subprocess. See [themes.md](themes.md).
16 18
17 ## Quick start19 ## Quick start
18 20
@@ -54,6 +56,10 @@ Drop it in a directory with this `extension.json`:
54 }56 }
55 ```57 ```
56 58
59+`exec` is required for protocol extensions. If an extension only ships
60+`theme.json` or `themes/theme.json`, no `exec` is required and zot does
61+not spawn a subprocess.
62+
57 `chmod +x hello.py`, install:63 `chmod +x hello.py`, install:
58 64
59 ```bash65 ```bash
A docs/themes.md +280 -0
@@ -0,0 +1,280 @@
1+# zot themes
2+
3+zot themes are JSON files that override any subset of the built-in
4+light/dark TUI theme. Nothing is required: a theme can change one
5+color, only the spinner, only syntax highlighting, or all of them.
6+Missing values inherit from zot's built-in default for the detected
7+terminal background.
8+
9+## Where themes live
10+
11+User themes are discovered from:
12+
13+```text
14+$ZOT_HOME/themes/*.json
15+```
16+
17+Open `/settings` and choose **color theme** to switch. Changes are
18+saved in `$ZOT_HOME/config.json` and apply immediately. If the selected
19+file is deleted later, zot resets the setting to the built-in auto
20+(default) theme.
21+
22+Theme files bundled with extensions are discovered in-place from loaded
23+extension directories:
24+
25+```text
26+$ZOT_HOME/extensions/<extension>/theme.json
27+$ZOT_HOME/extensions/<extension>/themes/theme.json
28+<project>/.zot/extensions/<extension>/theme.json
29+<project>/.zot/extensions/<extension>/themes/theme.json
30+```
31+
32+zot does **not** copy extension themes into `$ZOT_HOME/themes`; extension
33+owned themes stay in the extension directory. The settings picker shows
34+source info such as `from extension my-theme-extension`.
35+
36+## Minimal themes
37+
38+All of these are valid.
39+
40+Metadata only:
41+
42+```json
43+{
44+ "name": "my-theme",
45+ "description": "Metadata only; all visuals inherit zot defaults."
46+}
47+```
48+
49+One shared color for both light and dark terminals:
50+
51+```json
52+{
53+ "name": "pink-accent",
54+ "colors": {
55+ "accent": 204
56+ }
57+}
58+```
59+
60+One color per mode:
61+
62+```json
63+{
64+ "name": "split-accent",
65+ "colors": {
66+ "dark": { "accent": 204 },
67+ "light": { "accent": 161 }
68+ }
69+}
70+```
71+
72+Spinner-only:
73+
74+```json
75+{
76+ "name": "custom-spinner",
77+ "description": "Only changes the busy spinner.",
78+ "spinner_frames": ["◢", "◣", "◤", "◥"],
79+ "spinner_messages": ["working"],
80+ "spinner_interval_ms": 120
81+}
82+```
83+
84+Dark-only themes still work on light terminals. If `colors.light` is
85+missing, zot applies `colors.dark` overrides on top of the built-in
86+light default. The inverse also works.
87+
88+```json
89+{
90+ "name": "custom-spinner",
91+ "description": "An alternative spinner for zot that only displays a single spinner text.",
92+ "colors": {
93+ "dark": {
94+ "spinner_frames": ["◢", "◣", "◤", "◥"],
95+ "spinner_messages": ["working"],
96+ "spinner_interval_ms": 120
97+ }
98+ }
99+}
100+```
101+
102+## Full shape
103+
104+All fields are optional.
105+
106+```json
107+{
108+ "name": "my-theme",
109+ "description": "Shown in /settings → color theme.",
110+ "color_descriptions": {
111+ "accent": "Optional documentation for humans. zot ignores this object."
112+ },
113+ "colors": {
114+ "dark": {
115+ "fg": 253,
116+ "muted": 244,
117+ "accent": 111,
118+ "user": 180,
119+ "user_bubble_bg": "#42454b",
120+ "user_bubble_fg": 248,
121+ "assistant": 117,
122+ "tool": 114,
123+ "tool_out": 245,
124+ "error": 203,
125+ "warning": 214,
126+ "spinner": 183,
127+ "selection_bg": 24,
128+ "selection_fg": 231,
129+ "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"],
130+ "spinner_messages": ["thinking", "working"],
131+ "spinner_interval_ms": 80,
132+ "syntax_base_style": "monokai",
133+ "syntax": {
134+ "keyword": "#81a1c1 bold",
135+ "keyword_constant": "#81a1c1",
136+ "keyword_declaration": "#81a1c1",
137+ "keyword_namespace": "#81a1c1",
138+ "keyword_reserved": "#81a1c1 bold",
139+ "keyword_type": "#88c0d0",
140+ "name_builtin": "#88c0d0",
141+ "name_function": "#8fbcbb",
142+ "name_class": "#a3be8c bold",
143+ "name_decorator": "#b48ead",
144+ "literal_string": "#a3be8c",
145+ "literal_string_escape": "#bf616a",
146+ "literal_number": "#d08770",
147+ "comment": "#616e88 italic",
148+ "comment_preproc": "#b48ead",
149+ "operator": "#eceff4",
150+ "punctuation": "#d8dee9",
151+ "text": "#e5e9f0"
152+ }
153+ },
154+ "light": {
155+ "fg": 236,
156+ "muted": 244,
157+ "accent": 33
158+ }
159+ }
160+}
161+```
162+
163+You may also put overrides directly at the top level or directly under
164+`colors` when they should apply to both modes:
165+
166+```json
167+{
168+ "name": "tiny",
169+ "accent": 204,
170+ "colors": {
171+ "spinner_messages": ["shipping"]
172+ }
173+}
174+```
175+
176+## Color fields
177+
178+Most color fields are xterm-256 indexes (`0`–`255`).
179+
180+- `fg` — default foreground text.
181+- `muted` — secondary text, dividers, gutters, inactive hints.
182+- `accent` — prompt bar, bullets, links, headings, active markers.
183+- `user` — user role label color; mostly compatibility.
184+- `user_bubble_bg` — background behind user message rows.
185+- `user_bubble_fg` — foreground inside user message rows.
186+- `assistant` — assistant/zot accent and spinner text.
187+- `tool` — tool names, success marks, diff additions.
188+- `tool_out` — plain tool-output text.
189+- `error` — errors, refused calls, diff deletions.
190+- `warning` — warnings and high context-usage state.
191+- `spinner` — reserved spinner color slot.
192+- `selection_bg` — highlighted row background.
193+- `selection_fg` — highlighted row foreground.
194+
195+`user_bubble_bg` supports richer terminal color forms:
196+
197+```json
198+254
199+"#42454b"
200+{ "mode": "256", "index": 254 }
201+{ "mode": "ansi", "index": 100 }
202+{ "mode": "rgb", "r": 66, "g": 69, "b": 75 }
203+```
204+
205+## Spinner fields
206+
207+Spinner settings can appear at top level, under `colors`, or under
208+`colors.dark` / `colors.light`.
209+
210+- `spinner_frames` — list of frame strings. Single-cell glyphs keep
211+ status-bar alignment clean.
212+- `spinner_messages` — list of messages; zot picks one per turn.
213+- `spinner_interval_ms` — frame interval in milliseconds. Missing or
214+ invalid falls back to 80ms.
215+
216+## Syntax fields
217+
218+Syntax highlighting uses Chroma style entries. Values may include
219+attributes after the color, such as `bold`, `italic`, or `underline`.
220+
221+Supported syntax override keys:
222+
223+```text
224+keyword, keyword_constant, keyword_declaration, keyword_namespace,
225+keyword_reserved, keyword_type, name_builtin, name_function,
226+name_class, name_decorator, literal_string, literal_string_escape,
227+literal_number, comment, comment_preproc, operator, punctuation, text
228+```
229+
230+Example:
231+
232+```json
233+{
234+ "colors": {
235+ "dark": {
236+ "syntax_base_style": "monokai",
237+ "syntax": {
238+ "keyword": "#f05b8d",
239+ "name_function": "#b675f1",
240+ "literal_string": "#58c760",
241+ "comment": "#a1a1a1 italic"
242+ }
243+ }
244+ }
245+}
246+```
247+
248+## Theme-only extensions
249+
250+An extension can exist only to ship a theme. No slash command,
251+subprocess, or executable is required when the extension contains a
252+valid theme file.
253+
254+```text
255+$ZOT_HOME/extensions/my-theme-extension/
256+├── extension.json
257+└── theme.json
258+```
259+
260+`extension.json`:
261+
262+```json
263+{
264+ "name": "my-theme-extension",
265+ "version": "1.0.0",
266+ "description": "Ships a zot color theme",
267+ "enabled": true
268+}
269+```
270+
271+No `exec` is needed when `theme.json` or `themes/theme.json` exists.
272+If `exec` is present, zot treats it as a normal extension too.
273+
274+## Validate
275+
276+zot theme files are plain JSON, not JSONC. Validate before installing:
277+
278+```bash
279+python3 -m json.tool theme.json >/dev/null
280+```
@@ -0,0 +1,280 @@
1+# zot themes
2+
3+zot themes are JSON files that override any subset of the built-in
4+light/dark TUI theme. Nothing is required: a theme can change one
5+color, only the spinner, only syntax highlighting, or all of them.
6+Missing values inherit from zot's built-in default for the detected
7+terminal background.
8+
9+## Where themes live
10+
11+User themes are discovered from:
12+
13+```text
14+$ZOT_HOME/themes/*.json
15+```
16+
17+Open `/settings` and choose **color theme** to switch. Changes are
18+saved in `$ZOT_HOME/config.json` and apply immediately. If the selected
19+file is deleted later, zot resets the setting to the built-in auto
20+(default) theme.
21+
22+Theme files bundled with extensions are discovered in-place from loaded
23+extension directories:
24+
25+```text
26+$ZOT_HOME/extensions/<extension>/theme.json
27+$ZOT_HOME/extensions/<extension>/themes/theme.json
28+<project>/.zot/extensions/<extension>/theme.json
29+<project>/.zot/extensions/<extension>/themes/theme.json
30+```
31+
32+zot does **not** copy extension themes into `$ZOT_HOME/themes`; extension
33+owned themes stay in the extension directory. The settings picker shows
34+source info such as `from extension my-theme-extension`.
35+
36+## Minimal themes
37+
38+All of these are valid.
39+
40+Metadata only:
41+
42+```json
43+{
44+ "name": "my-theme",
45+ "description": "Metadata only; all visuals inherit zot defaults."
46+}
47+```
48+
49+One shared color for both light and dark terminals:
50+
51+```json
52+{
53+ "name": "pink-accent",
54+ "colors": {
55+ "accent": 204
56+ }
57+}
58+```
59+
60+One color per mode:
61+
62+```json
63+{
64+ "name": "split-accent",
65+ "colors": {
66+ "dark": { "accent": 204 },
67+ "light": { "accent": 161 }
68+ }
69+}
70+```
71+
72+Spinner-only:
73+
74+```json
75+{
76+ "name": "custom-spinner",
77+ "description": "Only changes the busy spinner.",
78+ "spinner_frames": ["◢", "◣", "◤", "◥"],
79+ "spinner_messages": ["working"],
80+ "spinner_interval_ms": 120
81+}
82+```
83+
84+Dark-only themes still work on light terminals. If `colors.light` is
85+missing, zot applies `colors.dark` overrides on top of the built-in
86+light default. The inverse also works.
87+
88+```json
89+{
90+ "name": "custom-spinner",
91+ "description": "An alternative spinner for zot that only displays a single spinner text.",
92+ "colors": {
93+ "dark": {
94+ "spinner_frames": ["◢", "◣", "◤", "◥"],
95+ "spinner_messages": ["working"],
96+ "spinner_interval_ms": 120
97+ }
98+ }
99+}
100+```
101+
102+## Full shape
103+
104+All fields are optional.
105+
106+```json
107+{
108+ "name": "my-theme",
109+ "description": "Shown in /settings → color theme.",
110+ "color_descriptions": {
111+ "accent": "Optional documentation for humans. zot ignores this object."
112+ },
113+ "colors": {
114+ "dark": {
115+ "fg": 253,
116+ "muted": 244,
117+ "accent": 111,
118+ "user": 180,
119+ "user_bubble_bg": "#42454b",
120+ "user_bubble_fg": 248,
121+ "assistant": 117,
122+ "tool": 114,
123+ "tool_out": 245,
124+ "error": 203,
125+ "warning": 214,
126+ "spinner": 183,
127+ "selection_bg": 24,
128+ "selection_fg": 231,
129+ "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"],
130+ "spinner_messages": ["thinking", "working"],
131+ "spinner_interval_ms": 80,
132+ "syntax_base_style": "monokai",
133+ "syntax": {
134+ "keyword": "#81a1c1 bold",
135+ "keyword_constant": "#81a1c1",
136+ "keyword_declaration": "#81a1c1",
137+ "keyword_namespace": "#81a1c1",
138+ "keyword_reserved": "#81a1c1 bold",
139+ "keyword_type": "#88c0d0",
140+ "name_builtin": "#88c0d0",
141+ "name_function": "#8fbcbb",
142+ "name_class": "#a3be8c bold",
143+ "name_decorator": "#b48ead",
144+ "literal_string": "#a3be8c",
145+ "literal_string_escape": "#bf616a",
146+ "literal_number": "#d08770",
147+ "comment": "#616e88 italic",
148+ "comment_preproc": "#b48ead",
149+ "operator": "#eceff4",
150+ "punctuation": "#d8dee9",
151+ "text": "#e5e9f0"
152+ }
153+ },
154+ "light": {
155+ "fg": 236,
156+ "muted": 244,
157+ "accent": 33
158+ }
159+ }
160+}
161+```
162+
163+You may also put overrides directly at the top level or directly under
164+`colors` when they should apply to both modes:
165+
166+```json
167+{
168+ "name": "tiny",
169+ "accent": 204,
170+ "colors": {
171+ "spinner_messages": ["shipping"]
172+ }
173+}
174+```
175+
176+## Color fields
177+
178+Most color fields are xterm-256 indexes (`0`–`255`).
179+
180+- `fg` — default foreground text.
181+- `muted` — secondary text, dividers, gutters, inactive hints.
182+- `accent` — prompt bar, bullets, links, headings, active markers.
183+- `user` — user role label color; mostly compatibility.
184+- `user_bubble_bg` — background behind user message rows.
185+- `user_bubble_fg` — foreground inside user message rows.
186+- `assistant` — assistant/zot accent and spinner text.
187+- `tool` — tool names, success marks, diff additions.
188+- `tool_out` — plain tool-output text.
189+- `error` — errors, refused calls, diff deletions.
190+- `warning` — warnings and high context-usage state.
191+- `spinner` — reserved spinner color slot.
192+- `selection_bg` — highlighted row background.
193+- `selection_fg` — highlighted row foreground.
194+
195+`user_bubble_bg` supports richer terminal color forms:
196+
197+```json
198+254
199+"#42454b"
200+{ "mode": "256", "index": 254 }
201+{ "mode": "ansi", "index": 100 }
202+{ "mode": "rgb", "r": 66, "g": 69, "b": 75 }
203+```
204+
205+## Spinner fields
206+
207+Spinner settings can appear at top level, under `colors`, or under
208+`colors.dark` / `colors.light`.
209+
210+- `spinner_frames` — list of frame strings. Single-cell glyphs keep
211+ status-bar alignment clean.
212+- `spinner_messages` — list of messages; zot picks one per turn.
213+- `spinner_interval_ms` — frame interval in milliseconds. Missing or
214+ invalid falls back to 80ms.
215+
216+## Syntax fields
217+
218+Syntax highlighting uses Chroma style entries. Values may include
219+attributes after the color, such as `bold`, `italic`, or `underline`.
220+
221+Supported syntax override keys:
222+
223+```text
224+keyword, keyword_constant, keyword_declaration, keyword_namespace,
225+keyword_reserved, keyword_type, name_builtin, name_function,
226+name_class, name_decorator, literal_string, literal_string_escape,
227+literal_number, comment, comment_preproc, operator, punctuation, text
228+```
229+
230+Example:
231+
232+```json
233+{
234+ "colors": {
235+ "dark": {
236+ "syntax_base_style": "monokai",
237+ "syntax": {
238+ "keyword": "#f05b8d",
239+ "name_function": "#b675f1",
240+ "literal_string": "#58c760",
241+ "comment": "#a1a1a1 italic"
242+ }
243+ }
244+ }
245+}
246+```
247+
248+## Theme-only extensions
249+
250+An extension can exist only to ship a theme. No slash command,
251+subprocess, or executable is required when the extension contains a
252+valid theme file.
253+
254+```text
255+$ZOT_HOME/extensions/my-theme-extension/
256+├── extension.json
257+└── theme.json
258+```
259+
260+`extension.json`:
261+
262+```json
263+{
264+ "name": "my-theme-extension",
265+ "version": "1.0.0",
266+ "description": "Ships a zot color theme",
267+ "enabled": true
268+}
269+```
270+
271+No `exec` is needed when `theme.json` or `themes/theme.json` exists.
272+If `exec` is present, zot treats it as a normal extension too.
273+
274+## Validate
275+
276+zot theme files are plain JSON, not JSONC. Validate before installing:
277+
278+```bash
279+python3 -m json.tool theme.json >/dev/null
280+```
A examples/theme/custom-spinner.json +22 -0
@@ -0,0 +1,22 @@
1+{
2+ "name": "custom-spinner",
3+ "description": "An alternative spinner for zot that only displays a single spinner text.",
4+ "colors": {
5+ "dark": {
6+ "spinner_frames": [
7+ "\u28FE",
8+ "\u28FD",
9+ "\u28FB",
10+ "\u28BF",
11+ "\u287F",
12+ "\u28DF",
13+ "\u28EF",
14+ "\u28F7"
15+ ],
16+ "spinner_messages": [
17+ "working"
18+ ],
19+ "spinner_interval_ms": 120
20+ }
21+ }
22+}
@@ -0,0 +1,22 @@
1+{
2+ "name": "custom-spinner",
3+ "description": "An alternative spinner for zot that only displays a single spinner text.",
4+ "colors": {
5+ "dark": {
6+ "spinner_frames": [
7+ "\u28FE",
8+ "\u28FD",
9+ "\u28FB",
10+ "\u28BF",
11+ "\u287F",
12+ "\u28DF",
13+ "\u28EF",
14+ "\u28F7"
15+ ],
16+ "spinner_messages": [
17+ "working"
18+ ],
19+ "spinner_interval_ms": 120
20+ }
21+ }
22+}
A examples/theme/vercel.json +126 -0
@@ -0,0 +1,126 @@
1+{
2+ "name": "vercel",
3+ "description": "Vercel-inspired zot theme based on gantoreno/vscode-vercel. Uses the same core palette as the VS Code theme: pink keywords, blue constants/links, purple functions, green strings/success, amber warnings, and neutral Vercel grays for chrome.",
4+ "color_descriptions": {
5+ "fg": "Default foreground text, based on Vercel editor.foreground / terminal.foreground.",
6+ "muted": "Secondary text, dividers, gutters, footers, inactive hints, based on Vercel foreground/sidebar/status gray.",
7+ "accent": "Primary UI accent for prompt bars, bullets, headings, and active markers; mapped to Vercel keyword pink for more visible theme identity.",
8+ "user": "User role label color; kept for compatibility with older UI surfaces.",
9+ "user_bubble_bg": "Background behind user message rows, based on Vercel editor.selectionBackground / lineHighlightBackground.",
10+ "user_bubble_fg": "Text foreground inside user message rows.",
11+ "assistant": "Assistant/zot accent color and welcome label, mapped to Vercel blue.",
12+ "tool": "Tool names, successful marks, additions in diffs, based on Vercel green.",
13+ "tool_out": "Plain tool output text.",
14+ "error": "Errors, refused tool calls, deletions in diffs, based on Vercel error/list error colors.",
15+ "warning": "Warnings and high context-usage state, based on Vercel modified/warning amber.",
16+ "spinner": "Secondary animated/accent color, based on Vercel magenta/purple token color.",
17+ "selection_bg": "Background for highlighted menu/list rows, based on Vercel selection background.",
18+ "selection_fg": "Foreground for highlighted menu/list rows.",
19+ "syntax_base_style": "Base Chroma style name used before syntax overrides.",
20+ "syntax": "Token colors for markdown code fences, file previews, and diff code, mapped from VS Code tokenColors. Values are Chroma style entries."
21+ },
22+ "colors": {
23+ "dark": {
24+ "fg": 255,
25+ "muted": 247,
26+ "accent": 204,
27+ "user": 141,
28+ "user_bubble_bg": "#1a1a1a",
29+ "user_bubble_fg": 255,
30+ "assistant": 75,
31+ "tool": 77,
32+ "tool_out": 247,
33+ "error": 204,
34+ "warning": 208,
35+ "spinner": 141,
36+ "selection_bg": 236,
37+ "selection_fg": 255,
38+ "syntax_base_style": "monokai",
39+ "syntax": {
40+ "keyword": "#f05b8d",
41+ "keyword_constant": "#62a6ff",
42+ "keyword_declaration": "#f05b8d",
43+ "keyword_namespace": "#f05b8d",
44+ "keyword_reserved": "#f05b8d",
45+ "keyword_type": "#b675f1",
46+ "name_builtin": "#62a6ff",
47+ "name_function": "#b675f1",
48+ "name_class": "#62a6ff",
49+ "name_decorator": "#b675f1",
50+ "literal_string": "#58c760",
51+ "literal_string_escape": "#62a6ff bold",
52+ "literal_number": "#62a6ff",
53+ "comment": "#a1a1a1 italic",
54+ "comment_preproc": "#b675f1",
55+ "operator": "#f05b8d",
56+ "punctuation": "#ededed",
57+ "text": "#ededed"
58+ },
59+ "spinner_frames": [
60+ "\u25d0",
61+ "\u25d3",
62+ "\u25d1",
63+ "\u25d2"
64+ ],
65+ "spinner_messages": [
66+ "shipping pixels",
67+ "deploying thoughts",
68+ "warming edge cache",
69+ "building preview",
70+ "checking redirects"
71+ ],
72+ "spinner_interval_ms": 120
73+ },
74+ "light": {
75+ "fg": 233,
76+ "muted": 242,
77+ "accent": 161,
78+ "user": 92,
79+ "user_bubble_bg": "#f5f5f5",
80+ "user_bubble_fg": 233,
81+ "assistant": 27,
82+ "tool": 29,
83+ "tool_out": 242,
84+ "error": 161,
85+ "warning": 130,
86+ "spinner": 92,
87+ "selection_bg": 251,
88+ "selection_fg": 233,
89+ "syntax_base_style": "github",
90+ "syntax": {
91+ "keyword": "#b32c62",
92+ "keyword_constant": "#005ee9",
93+ "keyword_declaration": "#b32c62",
94+ "keyword_namespace": "#b32c62",
95+ "keyword_reserved": "#b32c62",
96+ "keyword_type": "#7200c4",
97+ "name_builtin": "#005ee9",
98+ "name_function": "#7200c4",
99+ "name_class": "#005ee9",
100+ "name_decorator": "#7200c4",
101+ "literal_string": "#397c3b",
102+ "literal_string_escape": "#005ee9 bold",
103+ "literal_number": "#005ee9",
104+ "comment": "#666666 italic",
105+ "comment_preproc": "#7200c4",
106+ "operator": "#b32c62",
107+ "punctuation": "#171717",
108+ "text": "#171717"
109+ },
110+ "spinner_frames": [
111+ "\u25d0",
112+ "\u25d3",
113+ "\u25d1",
114+ "\u25d2"
115+ ],
116+ "spinner_messages": [
117+ "shipping pixels",
118+ "deploying thoughts",
119+ "warming edge cache",
120+ "building preview",
121+ "checking redirects"
122+ ],
123+ "spinner_interval_ms": 120
124+ }
125+ }
126+}
@@ -0,0 +1,126 @@
1+{
2+ "name": "vercel",
3+ "description": "Vercel-inspired zot theme based on gantoreno/vscode-vercel. Uses the same core palette as the VS Code theme: pink keywords, blue constants/links, purple functions, green strings/success, amber warnings, and neutral Vercel grays for chrome.",
4+ "color_descriptions": {
5+ "fg": "Default foreground text, based on Vercel editor.foreground / terminal.foreground.",
6+ "muted": "Secondary text, dividers, gutters, footers, inactive hints, based on Vercel foreground/sidebar/status gray.",
7+ "accent": "Primary UI accent for prompt bars, bullets, headings, and active markers; mapped to Vercel keyword pink for more visible theme identity.",
8+ "user": "User role label color; kept for compatibility with older UI surfaces.",
9+ "user_bubble_bg": "Background behind user message rows, based on Vercel editor.selectionBackground / lineHighlightBackground.",
10+ "user_bubble_fg": "Text foreground inside user message rows.",
11+ "assistant": "Assistant/zot accent color and welcome label, mapped to Vercel blue.",
12+ "tool": "Tool names, successful marks, additions in diffs, based on Vercel green.",
13+ "tool_out": "Plain tool output text.",
14+ "error": "Errors, refused tool calls, deletions in diffs, based on Vercel error/list error colors.",
15+ "warning": "Warnings and high context-usage state, based on Vercel modified/warning amber.",
16+ "spinner": "Secondary animated/accent color, based on Vercel magenta/purple token color.",
17+ "selection_bg": "Background for highlighted menu/list rows, based on Vercel selection background.",
18+ "selection_fg": "Foreground for highlighted menu/list rows.",
19+ "syntax_base_style": "Base Chroma style name used before syntax overrides.",
20+ "syntax": "Token colors for markdown code fences, file previews, and diff code, mapped from VS Code tokenColors. Values are Chroma style entries."
21+ },
22+ "colors": {
23+ "dark": {
24+ "fg": 255,
25+ "muted": 247,
26+ "accent": 204,
27+ "user": 141,
28+ "user_bubble_bg": "#1a1a1a",
29+ "user_bubble_fg": 255,
30+ "assistant": 75,
31+ "tool": 77,
32+ "tool_out": 247,
33+ "error": 204,
34+ "warning": 208,
35+ "spinner": 141,
36+ "selection_bg": 236,
37+ "selection_fg": 255,
38+ "syntax_base_style": "monokai",
39+ "syntax": {
40+ "keyword": "#f05b8d",
41+ "keyword_constant": "#62a6ff",
42+ "keyword_declaration": "#f05b8d",
43+ "keyword_namespace": "#f05b8d",
44+ "keyword_reserved": "#f05b8d",
45+ "keyword_type": "#b675f1",
46+ "name_builtin": "#62a6ff",
47+ "name_function": "#b675f1",
48+ "name_class": "#62a6ff",
49+ "name_decorator": "#b675f1",
50+ "literal_string": "#58c760",
51+ "literal_string_escape": "#62a6ff bold",
52+ "literal_number": "#62a6ff",
53+ "comment": "#a1a1a1 italic",
54+ "comment_preproc": "#b675f1",
55+ "operator": "#f05b8d",
56+ "punctuation": "#ededed",
57+ "text": "#ededed"
58+ },
59+ "spinner_frames": [
60+ "\u25d0",
61+ "\u25d3",
62+ "\u25d1",
63+ "\u25d2"
64+ ],
65+ "spinner_messages": [
66+ "shipping pixels",
67+ "deploying thoughts",
68+ "warming edge cache",
69+ "building preview",
70+ "checking redirects"
71+ ],
72+ "spinner_interval_ms": 120
73+ },
74+ "light": {
75+ "fg": 233,
76+ "muted": 242,
77+ "accent": 161,
78+ "user": 92,
79+ "user_bubble_bg": "#f5f5f5",
80+ "user_bubble_fg": 233,
81+ "assistant": 27,
82+ "tool": 29,
83+ "tool_out": 242,
84+ "error": 161,
85+ "warning": 130,
86+ "spinner": 92,
87+ "selection_bg": 251,
88+ "selection_fg": 233,
89+ "syntax_base_style": "github",
90+ "syntax": {
91+ "keyword": "#b32c62",
92+ "keyword_constant": "#005ee9",
93+ "keyword_declaration": "#b32c62",
94+ "keyword_namespace": "#b32c62",
95+ "keyword_reserved": "#b32c62",
96+ "keyword_type": "#7200c4",
97+ "name_builtin": "#005ee9",
98+ "name_function": "#7200c4",
99+ "name_class": "#005ee9",
100+ "name_decorator": "#7200c4",
101+ "literal_string": "#397c3b",
102+ "literal_string_escape": "#005ee9 bold",
103+ "literal_number": "#005ee9",
104+ "comment": "#666666 italic",
105+ "comment_preproc": "#7200c4",
106+ "operator": "#b32c62",
107+ "punctuation": "#171717",
108+ "text": "#171717"
109+ },
110+ "spinner_frames": [
111+ "\u25d0",
112+ "\u25d3",
113+ "\u25d1",
114+ "\u25d2"
115+ ],
116+ "spinner_messages": [
117+ "shipping pixels",
118+ "deploying thoughts",
119+ "warming edge cache",
120+ "building preview",
121+ "checking redirects"
122+ ],
123+ "spinner_interval_ms": 120
124+ }
125+ }
126+}
M packages/agent/cli.go +11 -1
@@ -911,6 +911,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
911911 }()
912912
913913 initialCfg, _ := LoadConfig()
914+ theme, _, themeErr := tui.DetectThemeWithCustom(ZotHome(), initialCfg.Theme, 80*time.Millisecond)
915+ if themeErr != nil {
916+ fmt.Fprintln(os.Stderr, "theme load:", themeErr)
917+ if initialCfg.Theme != "" && !tui.ThemeExists(ZotHome(), initialCfg.Theme) {
918+ initialCfg.Theme = ""
919+ _ = SaveConfig(initialCfg)
920+ }
921+ }
914922
915923 // swarmMgr was constructed and reloaded earlier (before the agent
916924 // build, so the auto-swarm tool could capture it). Here we just
@@ -927,9 +935,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
927935
928936 iv = modes.NewInteractive(modes.InteractiveConfig{
929937 Terminal: term,
930- Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),
938+ Theme: theme,
931939 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
932940 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
941+ ThemeName: initialCfg.Theme,
942+ ExtensionThemes: extMgr.ThemeOptions,
933943 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
934944 SettingsStore: configSettingsStore{},
935945 Model: r.Model,
@@ -911,6 +911,14 @@ func runInteractive(ctx context.Context, args Args, version string) error {
911 }()911 }()
912 912
913 initialCfg, _ := LoadConfig()913 initialCfg, _ := LoadConfig()
914+ theme, _, themeErr := tui.DetectThemeWithCustom(ZotHome(), initialCfg.Theme, 80*time.Millisecond)
915+ if themeErr != nil {
916+ fmt.Fprintln(os.Stderr, "theme load:", themeErr)
917+ if initialCfg.Theme != "" && !tui.ThemeExists(ZotHome(), initialCfg.Theme) {
918+ initialCfg.Theme = ""
919+ _ = SaveConfig(initialCfg)
920+ }
921+ }
914 922
915 // swarmMgr was constructed and reloaded earlier (before the agent923 // swarmMgr was constructed and reloaded earlier (before the agent
916 // build, so the auto-swarm tool could capture it). Here we just924 // build, so the auto-swarm tool could capture it). Here we just
@@ -927,9 +935,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
927 935
928 iv = modes.NewInteractive(modes.InteractiveConfig{936 iv = modes.NewInteractive(modes.InteractiveConfig{
929 Terminal: term,937 Terminal: term,
930- Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),938+ Theme: theme,
931 InlineImagesEnabled: initialCfg.InlineImagesEnabled,939 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
932 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,940 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
941+ ThemeName: initialCfg.Theme,
942+ ExtensionThemes: extMgr.ThemeOptions,
933 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,943 AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
934 SettingsStore: configSettingsStore{},944 SettingsStore: configSettingsStore{},
935 Model: r.Model,945 Model: r.Model,
M packages/agent/extensions/manager.go +56 -30
@@ -22,12 +22,14 @@ import (
2222 "os"
2323 "os/exec"
2424 "path/filepath"
25+ "sort"
2526 "strings"
2627 "sync"
2728 "syscall"
2829 "time"
2930
3031 "github.com/patriceckhart/zot/packages/agent/extproto"
32+ "github.com/patriceckhart/zot/packages/tui"
3133 )
3234
3335 // Manifest is the extension.json file shipped alongside an
@@ -249,7 +251,8 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
249251 if mf.Name == "" {
250252 return errors.New("manifest: name is required")
251253 }
252- if mf.Exec == "" {
254+ hasTheme := hasExtensionTheme(dir)
255+ if mf.Exec == "" && !hasTheme {
253256 return errors.New("manifest: exec is required")
254257 }
255258 if !mf.IsEnabled() {
@@ -275,8 +278,12 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
275278 eventSubs: map[string]struct{}{},
276279 interceptSubs: map[string]struct{}{},
277280 }
278- if err := m.spawn(ctx, ext); err != nil {
279- return err
281+ if mf.Exec != "" {
282+ if err := m.spawn(ctx, ext); err != nil {
283+ return err
284+ }
285+ } else {
286+ ext.readyOnce.Do(func() { close(ext.readyCh) })
280287 }
281288
282289 m.mu.Lock()
@@ -296,6 +303,37 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
296303 // Loaded BEFORE Discover so explicit paths win on name conflicts
297304 // against installed extensions. Spawns happen in parallel like the
298305 // regular discovery path; errors are returned per path.
306+func hasExtensionTheme(dir string) bool {
307+ for _, file := range []string{"theme.json", filepath.Join("themes", "theme.json")} {
308+ if _, err := os.Stat(filepath.Join(dir, file)); err == nil {
309+ return true
310+ }
311+ }
312+ return false
313+}
314+
315+func (m *Manager) ThemeOptions() []tui.ThemeOption {
316+ m.mu.RLock()
317+ defer m.mu.RUnlock()
318+ names := make([]string, 0, len(m.ext))
319+ for name := range m.ext {
320+ names = append(names, name)
321+ }
322+ sort.Strings(names)
323+ var out []tui.ThemeOption
324+ for _, name := range names {
325+ ext := m.ext[name]
326+ for _, file := range []string{"theme.json", "themes/theme.json"} {
327+ path := filepath.Join(ext.Dir, file)
328+ value := path
329+ if opt, ok := tui.ThemeOptionFromFile(path, value, "extension "+ext.Manifest.Name); ok {
330+ out = append(out, opt)
331+ }
332+ }
333+ }
334+ return out
335+}
336+
299337 func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error {
300338 if len(paths) == 0 {
301339 return nil
@@ -379,35 +417,11 @@ func (m *Manager) Reload(ctx context.Context, grace time.Duration) ReloadStats {
379417 // Graceful stop of the old set (reuses Stop's shutdown logic,
380418 // but Stop re-reads m.ext which is now empty, so we replicate
381419 // the small shutdown loop here on the snapshot).
420+ oldExts := make([]*Extension, 0, len(old))
382421 for _, ext := range old {
383- if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {
384- _, _ = ext.stdin.Write(frame)
385- }
386- _ = ext.stdin.Close()
387- }
388- deadline := time.Now().Add(grace)
389- for _, ext := range old {
390- remaining := time.Until(deadline)
391- if remaining <= 0 {
392- remaining = 100 * time.Millisecond
393- }
394- done := make(chan struct{})
395- go func(e *Extension) { _ = e.cmd.Wait(); close(done) }(ext)
396- select {
397- case <-done:
398- case <-time.After(remaining):
399- _ = ext.cmd.Process.Signal(syscall.SIGTERM)
400- select {
401- case <-done:
402- case <-time.After(time.Second):
403- _ = ext.cmd.Process.Kill()
404- <-done
405- }
406- }
407- if ext.logFile != nil {
408- _ = ext.logFile.Close()
409- }
422+ oldExts = append(oldExts, ext)
410423 }
424+ stopExtensions(oldExts, grace)
411425
412426 // Fresh load. Explicit paths first (they still win on conflict).
413427 if errs := m.LoadExplicit(ctx, explicit); len(errs) > 0 {
@@ -991,8 +1005,14 @@ func (m *Manager) Stop(gracePeriod time.Duration) {
9911005 exts = append(exts, e)
9921006 }
9931007 m.mu.RUnlock()
1008+ stopExtensions(exts, gracePeriod)
1009+}
9941010
1011+func stopExtensions(exts []*Extension, gracePeriod time.Duration) {
9951012 for _, ext := range exts {
1013+ if ext.stdin == nil {
1014+ continue
1015+ }
9961016 if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {
9971017 _, _ = ext.stdin.Write(frame)
9981018 }
@@ -1001,6 +1021,12 @@ func (m *Manager) Stop(gracePeriod time.Duration) {
10011021
10021022 deadline := time.Now().Add(gracePeriod)
10031023 for _, ext := range exts {
1024+ if ext.cmd == nil {
1025+ if ext.logFile != nil {
1026+ _ = ext.logFile.Close()
1027+ }
1028+ continue
1029+ }
10041030 remaining := time.Until(deadline)
10051031 if remaining <= 0 {
10061032 remaining = 100 * time.Millisecond
@@ -22,12 +22,14 @@ import (
22 "os"22 "os"
23 "os/exec"23 "os/exec"
24 "path/filepath"24 "path/filepath"
25+ "sort"
25 "strings"26 "strings"
26 "sync"27 "sync"
27 "syscall"28 "syscall"
28 "time"29 "time"
29 30
30 "github.com/patriceckhart/zot/packages/agent/extproto"31 "github.com/patriceckhart/zot/packages/agent/extproto"
32+ "github.com/patriceckhart/zot/packages/tui"
31 )33 )
32 34
33 // Manifest is the extension.json file shipped alongside an35 // Manifest is the extension.json file shipped alongside an
@@ -249,7 +251,8 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
249 if mf.Name == "" {251 if mf.Name == "" {
250 return errors.New("manifest: name is required")252 return errors.New("manifest: name is required")
251 }253 }
252- if mf.Exec == "" {254+ hasTheme := hasExtensionTheme(dir)
255+ if mf.Exec == "" && !hasTheme {
253 return errors.New("manifest: exec is required")256 return errors.New("manifest: exec is required")
254 }257 }
255 if !mf.IsEnabled() {258 if !mf.IsEnabled() {
@@ -275,8 +278,12 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
275 eventSubs: map[string]struct{}{},278 eventSubs: map[string]struct{}{},
276 interceptSubs: map[string]struct{}{},279 interceptSubs: map[string]struct{}{},
277 }280 }
278- if err := m.spawn(ctx, ext); err != nil {281+ if mf.Exec != "" {
279- return err282+ if err := m.spawn(ctx, ext); err != nil {
283+ return err
284+ }
285+ } else {
286+ ext.readyOnce.Do(func() { close(ext.readyCh) })
280 }287 }
281 288
282 m.mu.Lock()289 m.mu.Lock()
@@ -296,6 +303,37 @@ func (m *Manager) loadOne(ctx context.Context, dir string) error {
296 // Loaded BEFORE Discover so explicit paths win on name conflicts303 // Loaded BEFORE Discover so explicit paths win on name conflicts
297 // against installed extensions. Spawns happen in parallel like the304 // against installed extensions. Spawns happen in parallel like the
298 // regular discovery path; errors are returned per path.305 // regular discovery path; errors are returned per path.
306+func hasExtensionTheme(dir string) bool {
307+ for _, file := range []string{"theme.json", filepath.Join("themes", "theme.json")} {
308+ if _, err := os.Stat(filepath.Join(dir, file)); err == nil {
309+ return true
310+ }
311+ }
312+ return false
313+}
314+
315+func (m *Manager) ThemeOptions() []tui.ThemeOption {
316+ m.mu.RLock()
317+ defer m.mu.RUnlock()
318+ names := make([]string, 0, len(m.ext))
319+ for name := range m.ext {
320+ names = append(names, name)
321+ }
322+ sort.Strings(names)
323+ var out []tui.ThemeOption
324+ for _, name := range names {
325+ ext := m.ext[name]
326+ for _, file := range []string{"theme.json", "themes/theme.json"} {
327+ path := filepath.Join(ext.Dir, file)
328+ value := path
329+ if opt, ok := tui.ThemeOptionFromFile(path, value, "extension "+ext.Manifest.Name); ok {
330+ out = append(out, opt)
331+ }
332+ }
333+ }
334+ return out
335+}
336+
299 func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error {337 func (m *Manager) LoadExplicit(ctx context.Context, paths []string) []error {
300 if len(paths) == 0 {338 if len(paths) == 0 {
301 return nil339 return nil
@@ -379,35 +417,11 @@ func (m *Manager) Reload(ctx context.Context, grace time.Duration) ReloadStats {
379 // Graceful stop of the old set (reuses Stop's shutdown logic,417 // Graceful stop of the old set (reuses Stop's shutdown logic,
380 // but Stop re-reads m.ext which is now empty, so we replicate418 // but Stop re-reads m.ext which is now empty, so we replicate
381 // the small shutdown loop here on the snapshot).419 // the small shutdown loop here on the snapshot).
420+ oldExts := make([]*Extension, 0, len(old))
382 for _, ext := range old {421 for _, ext := range old {
383- if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {422+ oldExts = append(oldExts, ext)
384- _, _ = ext.stdin.Write(frame)
385- }
386- _ = ext.stdin.Close()
387- }
388- deadline := time.Now().Add(grace)
389- for _, ext := range old {
390- remaining := time.Until(deadline)
391- if remaining <= 0 {
392- remaining = 100 * time.Millisecond
393- }
394- done := make(chan struct{})
395- go func(e *Extension) { _ = e.cmd.Wait(); close(done) }(ext)
396- select {
397- case <-done:
398- case <-time.After(remaining):
399- _ = ext.cmd.Process.Signal(syscall.SIGTERM)
400- select {
401- case <-done:
402- case <-time.After(time.Second):
403- _ = ext.cmd.Process.Kill()
404- <-done
405- }
406- }
407- if ext.logFile != nil {
408- _ = ext.logFile.Close()
409- }
410 }423 }
424+ stopExtensions(oldExts, grace)
411 425
412 // Fresh load. Explicit paths first (they still win on conflict).426 // Fresh load. Explicit paths first (they still win on conflict).
413 if errs := m.LoadExplicit(ctx, explicit); len(errs) > 0 {427 if errs := m.LoadExplicit(ctx, explicit); len(errs) > 0 {
@@ -991,8 +1005,14 @@ func (m *Manager) Stop(gracePeriod time.Duration) {
991 exts = append(exts, e)1005 exts = append(exts, e)
992 }1006 }
993 m.mu.RUnlock()1007 m.mu.RUnlock()
1008+ stopExtensions(exts, gracePeriod)
1009+}
994 1010
1011+func stopExtensions(exts []*Extension, gracePeriod time.Duration) {
995 for _, ext := range exts {1012 for _, ext := range exts {
1013+ if ext.stdin == nil {
1014+ continue
1015+ }
996 if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {1016 if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {
997 _, _ = ext.stdin.Write(frame)1017 _, _ = ext.stdin.Write(frame)
998 }1018 }
@@ -1001,6 +1021,12 @@ func (m *Manager) Stop(gracePeriod time.Duration) {
1001 1021
1002 deadline := time.Now().Add(gracePeriod)1022 deadline := time.Now().Add(gracePeriod)
1003 for _, ext := range exts {1023 for _, ext := range exts {
1024+ if ext.cmd == nil {
1025+ if ext.logFile != nil {
1026+ _ = ext.logFile.Close()
1027+ }
1028+ continue
1029+ }
1004 remaining := time.Until(deadline)1030 remaining := time.Until(deadline)
1005 if remaining <= 0 {1031 if remaining <= 0 {
1006 remaining = 100 * time.Millisecond1032 remaining = 100 * time.Millisecond
M packages/agent/extensions/manager_test.go +34 -0
@@ -8,6 +8,7 @@ import (
88 "github.com/patriceckhart/zot/packages/agent/extproto"
99 "path/filepath"
1010 "runtime"
11+ "strings"
1112 "sync"
1213 "testing"
1314 "time"
@@ -88,6 +89,39 @@ done
8889 }
8990 }
9091
92+func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
93+ tmp := t.TempDir()
94+ extDir := filepath.Join(tmp, "extensions", "theme-only")
95+ if err := os.MkdirAll(extDir, 0o755); err != nil {
96+ t.Fatal(err)
97+ }
98+ manifest := `{"name":"theme-only","version":"1.0.0","description":"theme only"}`
99+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
100+ t.Fatal(err)
101+ }
102+ theme := `{"name":"Theme Only","description":"theme from extension","colors":{"dark":{"accent":204}}}`
103+ if err := os.WriteFile(filepath.Join(extDir, "theme.json"), []byte(theme), 0o644); err != nil {
104+ t.Fatal(err)
105+ }
106+
107+ mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", nil)
108+ if errs := mgr.Discover(context.Background()); len(errs) > 0 {
109+ t.Fatalf("discover errors: %v", errs)
110+ }
111+ defer mgr.Stop(10 * time.Millisecond)
112+
113+ opts := mgr.ThemeOptions()
114+ if len(opts) != 1 {
115+ t.Fatalf("theme options = %d, want 1", len(opts))
116+ }
117+ if opts[0].Label != "Theme Only" || opts[0].Path != filepath.Join(extDir, "theme.json") {
118+ t.Fatalf("unexpected theme option: %#v", opts[0])
119+ }
120+ if !strings.Contains(opts[0].Description, "from extension theme-only") {
121+ t.Fatalf("description missing extension source: %q", opts[0].Description)
122+ }
123+}
124+
91125 func TestManagerSpawnAndInvoke(t *testing.T) {
92126 tmp := t.TempDir()
93127 extRoot := filepath.Join(tmp, "extensions")
@@ -8,6 +8,7 @@ import (
8 "github.com/patriceckhart/zot/packages/agent/extproto"8 "github.com/patriceckhart/zot/packages/agent/extproto"
9 "path/filepath"9 "path/filepath"
10 "runtime"10 "runtime"
11+ "strings"
11 "sync"12 "sync"
12 "testing"13 "testing"
13 "time"14 "time"
@@ -88,6 +89,39 @@ done
88 }89 }
89 }90 }
90 91
92+func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
93+ tmp := t.TempDir()
94+ extDir := filepath.Join(tmp, "extensions", "theme-only")
95+ if err := os.MkdirAll(extDir, 0o755); err != nil {
96+ t.Fatal(err)
97+ }
98+ manifest := `{"name":"theme-only","version":"1.0.0","description":"theme only"}`
99+ if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
100+ t.Fatal(err)
101+ }
102+ theme := `{"name":"Theme Only","description":"theme from extension","colors":{"dark":{"accent":204}}}`
103+ if err := os.WriteFile(filepath.Join(extDir, "theme.json"), []byte(theme), 0o644); err != nil {
104+ t.Fatal(err)
105+ }
106+
107+ mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", nil)
108+ if errs := mgr.Discover(context.Background()); len(errs) > 0 {
109+ t.Fatalf("discover errors: %v", errs)
110+ }
111+ defer mgr.Stop(10 * time.Millisecond)
112+
113+ opts := mgr.ThemeOptions()
114+ if len(opts) != 1 {
115+ t.Fatalf("theme options = %d, want 1", len(opts))
116+ }
117+ if opts[0].Label != "Theme Only" || opts[0].Path != filepath.Join(extDir, "theme.json") {
118+ t.Fatalf("unexpected theme option: %#v", opts[0])
119+ }
120+ if !strings.Contains(opts[0].Description, "from extension theme-only") {
121+ t.Fatalf("description missing extension source: %q", opts[0].Description)
122+ }
123+}
124+
91 func TestManagerSpawnAndInvoke(t *testing.T) {125 func TestManagerSpawnAndInvoke(t *testing.T) {
92 tmp := t.TempDir()126 tmp := t.TempDir()
93 extRoot := filepath.Join(tmp, "extensions")127 extRoot := filepath.Join(tmp, "extensions")
M packages/agent/modes/btw_dialog.go +3 -1
@@ -208,7 +208,9 @@ func (d *btwDialog) submit(invalidate func()) {
208208 d.editor.Clear()
209209 d.loading = true
210210 if d.spin == nil {
211- d.spin = newSpinner()
211+ d.spin = newSpinner(d.theme)
212+ } else {
213+ d.spin.Configure(d.theme)
212214 }
213215 d.spin.Start()
214216 d.turns = append(d.turns, btwTurn{User: question})
@@ -208,7 +208,9 @@ func (d *btwDialog) submit(invalidate func()) {
208 d.editor.Clear()208 d.editor.Clear()
209 d.loading = true209 d.loading = true
210 if d.spin == nil {210 if d.spin == nil {
211- d.spin = newSpinner()211+ d.spin = newSpinner(d.theme)
212+ } else {
213+ d.spin.Configure(d.theme)
212 }214 }
213 d.spin.Start()215 d.spin.Start()
214 d.turns = append(d.turns, btwTurn{User: question})216 d.turns = append(d.turns, btwTurn{User: question})
M packages/agent/modes/ext_panel_dialog.go +2 -3
@@ -1,7 +1,6 @@
11 package modes
22
33 import (
4- "fmt"
54 "strings"
65
76 "github.com/mattn/go-runewidth"
@@ -86,8 +85,8 @@ func styleExtPanelLine(th tui.Theme, raw string, plain string, width int, select
8685 if visible := runewidth.StringWidth(plain); visible < width {
8786 plain += strings.Repeat(" ", width-visible)
8887 }
89- base := fmt.Sprintf("\x1b[38;5;%dm\x1b[48;5;%dm", th.SelectionFG, th.SelectionBG)
90- green := fmt.Sprintf("\x1b[38;5;%dm\x1b[48;5;%dm", th.Tool, th.SelectionBG)
88+ base := th.SelectionStyle()
89+ green := th.SelectionStyleFG(th.Tool)
9190 return base + strings.ReplaceAll(plain, "✓", green+"✓"+base) + "\x1b[0m"
9291 }
9392 if raw != plain {
@@ -1,7 +1,6 @@
1 package modes1 package modes
2 2
3 import (3 import (
4- "fmt"
5 "strings"4 "strings"
6 5
7 "github.com/mattn/go-runewidth"6 "github.com/mattn/go-runewidth"
@@ -86,8 +85,8 @@ func styleExtPanelLine(th tui.Theme, raw string, plain string, width int, select
86 if visible := runewidth.StringWidth(plain); visible < width {85 if visible := runewidth.StringWidth(plain); visible < width {
87 plain += strings.Repeat(" ", width-visible)86 plain += strings.Repeat(" ", width-visible)
88 }87 }
89- base := fmt.Sprintf("\x1b[38;5;%dm\x1b[48;5;%dm", th.SelectionFG, th.SelectionBG)88+ base := th.SelectionStyle()
90- green := fmt.Sprintf("\x1b[38;5;%dm\x1b[48;5;%dm", th.Tool, th.SelectionBG)89+ green := th.SelectionStyleFG(th.Tool)
91 return base + strings.ReplaceAll(plain, "✓", green+"✓"+base) + "\x1b[0m"90 return base + strings.ReplaceAll(plain, "✓", green+"✓"+base) + "\x1b[0m"
92 }91 }
93 if raw != plain {92 if raw != plain {
M packages/agent/modes/interactive.go +100 -4
@@ -45,6 +45,11 @@ type InteractiveConfig struct {
4545 // re-reading config.json on every open.
4646 AutoSwarmEnabled *bool
4747
48+ // ThemeName mirrors the persisted config theme value. Empty means auto.
49+ ThemeName string
50+ // ExtensionThemes returns themes bundled with loaded extensions.
51+ ExtensionThemes func() []tui.ThemeOption
52+
4853 // AutoSwarmSystemAddendum is the system-prompt block that gets
4954 // appended/stripped when the user toggles auto-swarm at runtime.
5055 // Plumbed in from the cli so this package doesn't have to import
@@ -222,6 +227,7 @@ type SettingsStore interface {
222227 SetInlineImages(enabled bool) error
223228 SetAutoSwarm(enabled bool) error
224229 SetReasoning(level string) error
230+ SetTheme(name string) error
225231 }
226232
227233 type Interactive struct {
@@ -440,7 +446,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
440446 extPanel: newExtPanelDialog(),
441447 suggest: newSlashSuggester(),
442448 fileSuggest: newFileSuggester(),
443- spin: newSpinner(),
449+ spin: newSpinner(cfg.Theme),
444450 inputHistoryIndex: -1,
445451 }
446452 if cfg.Agent != nil {
@@ -2526,6 +2532,31 @@ func (i *Interactive) openSettingsDialog() {
25262532 reasoningHint = "current model does not support thinking"
25272533 }
25282534
2535+ themeName := i.cfg.ThemeName
2536+ if themeName == "" {
2537+ themeName = "auto"
2538+ }
2539+ if themeName != "auto" && !tui.ThemeExists(i.cfg.ZotHome, themeName) {
2540+ themeName = "auto"
2541+ i.cfg.ThemeName = ""
2542+ if i.cfg.SettingsStore != nil {
2543+ _ = i.cfg.SettingsStore.SetTheme("auto")
2544+ }
2545+ i.applyThemeNow("auto")
2546+ }
2547+ themeOptions := []settingsOption{}
2548+ themeChoice := 0
2549+ availableThemes := tui.AvailableThemes(i.cfg.ZotHome)
2550+ if i.cfg.ExtensionThemes != nil {
2551+ availableThemes = append(availableThemes, i.cfg.ExtensionThemes()...)
2552+ }
2553+ for idx, opt := range availableThemes {
2554+ themeOptions = append(themeOptions, settingsOption{value: opt.Value, label: opt.Label, desc: opt.Description})
2555+ if opt.Value == themeName {
2556+ themeChoice = idx
2557+ }
2558+ }
2559+
25292560 i.settingsDialog.Open([]settingsItem{
25302561 {
25312562 key: "inline_images_enabled",
@@ -2551,15 +2582,25 @@ func (i *Interactive) openSettingsDialog() {
25512582 choice: reasoningChoice,
25522583 hint: reasoningHint,
25532584 },
2585+ {
2586+ key: "theme",
2587+ label: "color theme",
2588+ desc: "choose a theme from $ZOT_HOME/themes or a loaded extension",
2589+ options: themeOptions,
2590+ choice: themeChoice,
2591+ },
25542592 })
25552593 }
25562594
25572595 func (i *Interactive) applySettingChange(act settingsAction) {
2558- if act.Key == "reasoning" {
2596+ switch act.Key {
2597+ case "reasoning":
25592598 i.applyReasoningSetting(act.StringValue)
2560- return
2599+ case "theme":
2600+ i.applyThemeSetting(act.StringValue)
2601+ default:
2602+ i.applySettingToggle(act.Key, act.Value)
25612603 }
2562- i.applySettingToggle(act.Key, act.Value)
25632604 }
25642605
25652606 func (i *Interactive) applySettingToggle(key string, value bool) {
@@ -2618,6 +2659,61 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
26182659 }
26192660 }
26202661
2662+func (i *Interactive) applyThemeSetting(name string) {
2663+ if i.cfg.SettingsStore != nil {
2664+ if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
2665+ i.mu.Lock()
2666+ i.statusErr = "settings: " + err.Error()
2667+ i.mu.Unlock()
2668+ return
2669+ }
2670+ }
2671+ i.cfg.ThemeName = name
2672+ if name == "auto" {
2673+ i.cfg.ThemeName = ""
2674+ }
2675+ i.applyThemeNow(name)
2676+}
2677+
2678+func (i *Interactive) applyThemeNow(name string) {
2679+ if name == "" {
2680+ name = "auto"
2681+ }
2682+ detected := tui.Dark
2683+ if tui.IsLightTheme(i.cfg.Theme) {
2684+ detected = tui.Light
2685+ }
2686+ th, applied, err := tui.LoadThemeFromHome(i.cfg.ZotHome, name, detected)
2687+ if err != nil {
2688+ if i.cfg.SettingsStore != nil {
2689+ _ = i.cfg.SettingsStore.SetTheme("auto")
2690+ }
2691+ i.cfg.ThemeName = ""
2692+ th, applied, _ = tui.LoadThemeFromHome(i.cfg.ZotHome, "auto", detected)
2693+ i.mu.Lock()
2694+ i.statusErr = "theme missing; reset to default"
2695+ i.mu.Unlock()
2696+ } else {
2697+ i.mu.Lock()
2698+ label := applied
2699+ if label == "" {
2700+ label = "auto"
2701+ }
2702+ i.statusOK = "theme " + label
2703+ i.statusErr = ""
2704+ i.mu.Unlock()
2705+ }
2706+ i.cfg.Theme = th
2707+ i.view.Theme = th
2708+ i.view.InvalidateRenderCache()
2709+ i.ed.Prompt = th.AccentBar(th.Accent)
2710+ i.spin.Configure(th)
2711+ if i.rend != nil {
2712+ i.rend.Clear()
2713+ }
2714+ i.invalidate()
2715+}
2716+
26212717 func (i *Interactive) applyReasoningSetting(level string) {
26222718 defer func() {
26232719 if i.rend != nil {
@@ -45,6 +45,11 @@ type InteractiveConfig struct {
45 // re-reading config.json on every open.45 // re-reading config.json on every open.
46 AutoSwarmEnabled *bool46 AutoSwarmEnabled *bool
47 47
48+ // ThemeName mirrors the persisted config theme value. Empty means auto.
49+ ThemeName string
50+ // ExtensionThemes returns themes bundled with loaded extensions.
51+ ExtensionThemes func() []tui.ThemeOption
52+
48 // AutoSwarmSystemAddendum is the system-prompt block that gets53 // AutoSwarmSystemAddendum is the system-prompt block that gets
49 // appended/stripped when the user toggles auto-swarm at runtime.54 // appended/stripped when the user toggles auto-swarm at runtime.
50 // Plumbed in from the cli so this package doesn't have to import55 // Plumbed in from the cli so this package doesn't have to import
@@ -222,6 +227,7 @@ type SettingsStore interface {
222 SetInlineImages(enabled bool) error227 SetInlineImages(enabled bool) error
223 SetAutoSwarm(enabled bool) error228 SetAutoSwarm(enabled bool) error
224 SetReasoning(level string) error229 SetReasoning(level string) error
230+ SetTheme(name string) error
225 }231 }
226 232
227 type Interactive struct {233 type Interactive struct {
@@ -440,7 +446,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
440 extPanel: newExtPanelDialog(),446 extPanel: newExtPanelDialog(),
441 suggest: newSlashSuggester(),447 suggest: newSlashSuggester(),
442 fileSuggest: newFileSuggester(),448 fileSuggest: newFileSuggester(),
443- spin: newSpinner(),449+ spin: newSpinner(cfg.Theme),
444 inputHistoryIndex: -1,450 inputHistoryIndex: -1,
445 }451 }
446 if cfg.Agent != nil {452 if cfg.Agent != nil {
@@ -2526,6 +2532,31 @@ func (i *Interactive) openSettingsDialog() {
2526 reasoningHint = "current model does not support thinking"2532 reasoningHint = "current model does not support thinking"
2527 }2533 }
2528 2534
2535+ themeName := i.cfg.ThemeName
2536+ if themeName == "" {
2537+ themeName = "auto"
2538+ }
2539+ if themeName != "auto" && !tui.ThemeExists(i.cfg.ZotHome, themeName) {
2540+ themeName = "auto"
2541+ i.cfg.ThemeName = ""
2542+ if i.cfg.SettingsStore != nil {
2543+ _ = i.cfg.SettingsStore.SetTheme("auto")
2544+ }
2545+ i.applyThemeNow("auto")
2546+ }
2547+ themeOptions := []settingsOption{}
2548+ themeChoice := 0
2549+ availableThemes := tui.AvailableThemes(i.cfg.ZotHome)
2550+ if i.cfg.ExtensionThemes != nil {
2551+ availableThemes = append(availableThemes, i.cfg.ExtensionThemes()...)
2552+ }
2553+ for idx, opt := range availableThemes {
2554+ themeOptions = append(themeOptions, settingsOption{value: opt.Value, label: opt.Label, desc: opt.Description})
2555+ if opt.Value == themeName {
2556+ themeChoice = idx
2557+ }
2558+ }
2559+
2529 i.settingsDialog.Open([]settingsItem{2560 i.settingsDialog.Open([]settingsItem{
2530 {2561 {
2531 key: "inline_images_enabled",2562 key: "inline_images_enabled",
@@ -2551,15 +2582,25 @@ func (i *Interactive) openSettingsDialog() {
2551 choice: reasoningChoice,2582 choice: reasoningChoice,
2552 hint: reasoningHint,2583 hint: reasoningHint,
2553 },2584 },
2585+ {
2586+ key: "theme",
2587+ label: "color theme",
2588+ desc: "choose a theme from $ZOT_HOME/themes or a loaded extension",
2589+ options: themeOptions,
2590+ choice: themeChoice,
2591+ },
2554 })2592 })
2555 }2593 }
2556 2594
2557 func (i *Interactive) applySettingChange(act settingsAction) {2595 func (i *Interactive) applySettingChange(act settingsAction) {
2558- if act.Key == "reasoning" {2596+ switch act.Key {
2597+ case "reasoning":
2559 i.applyReasoningSetting(act.StringValue)2598 i.applyReasoningSetting(act.StringValue)
2560- return2599+ case "theme":
2600+ i.applyThemeSetting(act.StringValue)
2601+ default:
2602+ i.applySettingToggle(act.Key, act.Value)
2561 }2603 }
2562- i.applySettingToggle(act.Key, act.Value)
2563 }2604 }
2564 2605
2565 func (i *Interactive) applySettingToggle(key string, value bool) {2606 func (i *Interactive) applySettingToggle(key string, value bool) {
@@ -2618,6 +2659,61 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
2618 }2659 }
2619 }2660 }
2620 2661
2662+func (i *Interactive) applyThemeSetting(name string) {
2663+ if i.cfg.SettingsStore != nil {
2664+ if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
2665+ i.mu.Lock()
2666+ i.statusErr = "settings: " + err.Error()
2667+ i.mu.Unlock()
2668+ return
2669+ }
2670+ }
2671+ i.cfg.ThemeName = name
2672+ if name == "auto" {
2673+ i.cfg.ThemeName = ""
2674+ }
2675+ i.applyThemeNow(name)
2676+}
2677+
2678+func (i *Interactive) applyThemeNow(name string) {
2679+ if name == "" {
2680+ name = "auto"
2681+ }
2682+ detected := tui.Dark
2683+ if tui.IsLightTheme(i.cfg.Theme) {
2684+ detected = tui.Light
2685+ }
2686+ th, applied, err := tui.LoadThemeFromHome(i.cfg.ZotHome, name, detected)
2687+ if err != nil {
2688+ if i.cfg.SettingsStore != nil {
2689+ _ = i.cfg.SettingsStore.SetTheme("auto")
2690+ }
2691+ i.cfg.ThemeName = ""
2692+ th, applied, _ = tui.LoadThemeFromHome(i.cfg.ZotHome, "auto", detected)
2693+ i.mu.Lock()
2694+ i.statusErr = "theme missing; reset to default"
2695+ i.mu.Unlock()
2696+ } else {
2697+ i.mu.Lock()
2698+ label := applied
2699+ if label == "" {
2700+ label = "auto"
2701+ }
2702+ i.statusOK = "theme " + label
2703+ i.statusErr = ""
2704+ i.mu.Unlock()
2705+ }
2706+ i.cfg.Theme = th
2707+ i.view.Theme = th
2708+ i.view.InvalidateRenderCache()
2709+ i.ed.Prompt = th.AccentBar(th.Accent)
2710+ i.spin.Configure(th)
2711+ if i.rend != nil {
2712+ i.rend.Clear()
2713+ }
2714+ i.invalidate()
2715+}
2716+
2621 func (i *Interactive) applyReasoningSetting(level string) {2717 func (i *Interactive) applyReasoningSetting(level string) {
2622 defer func() {2718 defer func() {
2623 if i.rend != nil {2719 if i.rend != nil {
M packages/agent/modes/settings_dialog.go +37 -4
@@ -1,6 +1,10 @@
11 package modes
22
33 import (
4+ "strings"
5+
6+ "github.com/mattn/go-runewidth"
7+
48 "github.com/patriceckhart/zot/packages/tui"
59 )
610
@@ -160,11 +164,11 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string {
160164 for i, it := range d.items {
161165 box := "[ ]"
162166 if it.value {
163- box = "[x]"
167+ box = "[✓]"
164168 }
165169 plain := " " + box + " " + it.label
166170 if len(it.options) > 0 {
167- box = "[>]"
171+ box = "[→]"
168172 if it.choice < 0 || it.choice >= len(it.options) {
169173 it.choice = 0
170174 }
@@ -181,7 +185,9 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string {
181185 lines = append(lines, plain)
182186 }
183187 if it.desc != "" {
184- lines = append(lines, th.FG256(th.Muted, " "+it.desc))
188+ for _, desc := range wrapSettingDescription(it.desc, width, 6) {
189+ lines = append(lines, th.FG256(th.Muted, desc))
190+ }
185191 }
186192 }
187193 lines = append(lines, frameRule(th, width))
@@ -211,9 +217,36 @@ func (d *settingsDialog) renderOptions(th tui.Theme, width int) []string {
211217 lines = append(lines, plain)
212218 }
213219 if opt.desc != "" {
214- lines = append(lines, th.FG256(th.Muted, " "+opt.desc))
220+ for _, desc := range wrapSettingDescription(opt.desc, width, 6) {
221+ lines = append(lines, th.FG256(th.Muted, desc))
222+ }
215223 }
216224 }
217225 lines = append(lines, frameRule(th, width))
218226 return lines
219227 }
228+
229+func wrapSettingDescription(desc string, width, indent int) []string {
230+ prefix := strings.Repeat(" ", indent)
231+ limit := width - indent
232+ if limit < 20 {
233+ limit = 20
234+ }
235+ words := strings.Fields(desc)
236+ if len(words) == 0 {
237+ return nil
238+ }
239+ var lines []string
240+ line := words[0]
241+ for _, word := range words[1:] {
242+ candidate := line + " " + word
243+ if runewidth.StringWidth(candidate) <= limit {
244+ line = candidate
245+ continue
246+ }
247+ lines = append(lines, prefix+line)
248+ line = word
249+ }
250+ lines = append(lines, prefix+line)
251+ return lines
252+}
@@ -1,6 +1,10 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "strings"
5+
6+ "github.com/mattn/go-runewidth"
7+
4 "github.com/patriceckhart/zot/packages/tui"8 "github.com/patriceckhart/zot/packages/tui"
5 )9 )
6 10
@@ -160,11 +164,11 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string {
160 for i, it := range d.items {164 for i, it := range d.items {
161 box := "[ ]"165 box := "[ ]"
162 if it.value {166 if it.value {
163- box = "[x]"167+ box = "[✓]"
164 }168 }
165 plain := " " + box + " " + it.label169 plain := " " + box + " " + it.label
166 if len(it.options) > 0 {170 if len(it.options) > 0 {
167- box = "[>]"171+ box = "[→]"
168 if it.choice < 0 || it.choice >= len(it.options) {172 if it.choice < 0 || it.choice >= len(it.options) {
169 it.choice = 0173 it.choice = 0
170 }174 }
@@ -181,7 +185,9 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string {
181 lines = append(lines, plain)185 lines = append(lines, plain)
182 }186 }
183 if it.desc != "" {187 if it.desc != "" {
184- lines = append(lines, th.FG256(th.Muted, " "+it.desc))188+ for _, desc := range wrapSettingDescription(it.desc, width, 6) {
189+ lines = append(lines, th.FG256(th.Muted, desc))
190+ }
185 }191 }
186 }192 }
187 lines = append(lines, frameRule(th, width))193 lines = append(lines, frameRule(th, width))
@@ -211,9 +217,36 @@ func (d *settingsDialog) renderOptions(th tui.Theme, width int) []string {
211 lines = append(lines, plain)217 lines = append(lines, plain)
212 }218 }
213 if opt.desc != "" {219 if opt.desc != "" {
214- lines = append(lines, th.FG256(th.Muted, " "+opt.desc))220+ for _, desc := range wrapSettingDescription(opt.desc, width, 6) {
221+ lines = append(lines, th.FG256(th.Muted, desc))
222+ }
215 }223 }
216 }224 }
217 lines = append(lines, frameRule(th, width))225 lines = append(lines, frameRule(th, width))
218 return lines226 return lines
219 }227 }
228+
229+func wrapSettingDescription(desc string, width, indent int) []string {
230+ prefix := strings.Repeat(" ", indent)
231+ limit := width - indent
232+ if limit < 20 {
233+ limit = 20
234+ }
235+ words := strings.Fields(desc)
236+ if len(words) == 0 {
237+ return nil
238+ }
239+ var lines []string
240+ line := words[0]
241+ for _, word := range words[1:] {
242+ candidate := line + " " + word
243+ if runewidth.StringWidth(candidate) <= limit {
244+ line = candidate
245+ continue
246+ }
247+ lines = append(lines, prefix+line)
248+ line = word
249+ }
250+ lines = append(lines, prefix+line)
251+ return lines
252+}
M packages/agent/modes/spinner.go +41 -57
@@ -3,6 +3,8 @@ package modes
33 import (
44 "math/rand"
55 "time"
6+
7+ "github.com/patriceckhart/zot/packages/tui"
68 )
79
810 // spinner drives the busy animation shown in the status bar while a
@@ -11,6 +13,7 @@ import (
1113 type spinner struct {
1214 frames []string
1315 messages []string
16+ interval time.Duration
1417 startedAt time.Time
1518 msgIdx int
1619
@@ -20,63 +23,30 @@ type spinner struct {
2023 fixedMsg string
2124 }
2225
23-// funnyWorkingLines is the rotating text. Kept deliberately short so it
24-// fits next to the token counter on narrow terminals. A handful of
25-// craftsman-style aphorisms are folded in among the irreverent lines
26-// for a bit of contrast — the rotation never feels too uniform.
27-var funnyWorkingLines = []string{
28- "thinking",
29- "reticulating splines",
30- "bribing the tokenizer",
31- "asking the rubber duck",
32- "summoning daemons",
33- "consulting the oracle",
34- "herding tokens",
35- "compiling excuses",
36- "poking the model",
37- "negotiating with rate limits",
38- "picking a fight with syntax",
39- "reading between the bits",
40- "tasting the semicolons",
41- "pretending to understand the code",
42- "petting the cache",
43- "drafting clever replies",
44- "warming up the GPU choir",
45- "arguing with a stack trace",
46- "googling the answer (not really)",
47- "rewriting history",
48- "every draft is a stone in the work",
49- "bringing order to the unhewn",
50- "finding the load-bearing measure",
51- "where clarity grows, work grows lighter",
52- "every correction serves the work",
53-}
54-
55-// spinnerFrames is the cli-spinners "dots3" preset — a 10-frame
56-// single-cell braille spinner that reads as a small box of dots
57-// rotating around its perimeter. Source:
58-// https://github.com/sindresorhus/cli-spinners (MIT). Used at the
59-// preset's recommended 80ms per step.
60-var spinnerFrames = []string{
61- "⠋",
62- "⠙",
63- "⠚",
64- "⠞",
65- "⠖",
66- "⠦",
67- "⠴",
68- "⠲",
69- "⠳",
70- "⠓",
26+// newSpinner constructs a fresh spinner.
27+func newSpinner(th tui.Theme) *spinner {
28+ s := &spinner{}
29+ s.Configure(th)
30+ return s
7131 }
7232
73-// newSpinner constructs a fresh spinner.
74-func newSpinner() *spinner {
75- s := &spinner{
76- frames: spinnerFrames,
77- messages: funnyWorkingLines,
33+func (s *spinner) Configure(th tui.Theme) {
34+ s.frames = append([]string(nil), th.SpinnerFrames...)
35+ if len(s.frames) == 0 {
36+ s.frames = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"}
37+ }
38+ s.messages = append([]string(nil), th.SpinnerMessages...)
39+ if len(s.messages) == 0 {
40+ s.messages = []string{"thinking"}
41+ }
42+ interval := th.SpinnerIntervalMS
43+ if interval <= 0 {
44+ interval = 80
45+ }
46+ s.interval = time.Duration(interval) * time.Millisecond
47+ if s.msgIdx >= len(s.messages) {
48+ s.msgIdx = 0
7849 }
79- return s
8050 }
8151
8252 // Start resets the spinner to the beginning of its animation and
@@ -89,6 +59,9 @@ func newSpinner() *spinner {
8959 // still keeps the set fresh over a session.
9060 func (s *spinner) Start() {
9161 s.startedAt = time.Now()
62+ if len(s.messages) == 0 {
63+ s.messages = []string{"thinking"}
64+ }
9265 s.msgIdx = rand.Intn(len(s.messages))
9366 s.fixedMsg = ""
9467 }
@@ -101,14 +74,19 @@ func (s *spinner) StartFixed(msg string) {
10174 }
10275
10376 // Frame returns the current spinner glyph for the running animation.
104-//
105-// 80ms per step matches the dots3 preset's recommended interval.
10677 func (s *spinner) Frame() string {
78+ if len(s.frames) == 0 {
79+ return ""
80+ }
10781 if s.startedAt.IsZero() {
10882 return s.frames[0]
10983 }
84+ interval := s.interval
85+ if interval <= 0 {
86+ interval = 80 * time.Millisecond
87+ }
11088 elapsed := time.Since(s.startedAt)
111- idx := int(elapsed/(80*time.Millisecond)) % len(s.frames)
89+ idx := int(elapsed/interval) % len(s.frames)
11290 return s.frames[idx]
11391 }
11492
@@ -120,6 +98,12 @@ func (s *spinner) Message() string {
12098 if s.fixedMsg != "" {
12199 return s.fixedMsg
122100 }
101+ if len(s.messages) == 0 {
102+ return "thinking"
103+ }
104+ if s.msgIdx < 0 || s.msgIdx >= len(s.messages) {
105+ s.msgIdx = 0
106+ }
123107 return s.messages[s.msgIdx]
124108 }
125109
@@ -3,6 +3,8 @@ package modes
3 import (3 import (
4 "math/rand"4 "math/rand"
5 "time"5 "time"
6+
7+ "github.com/patriceckhart/zot/packages/tui"
6 )8 )
7 9
8 // spinner drives the busy animation shown in the status bar while a10 // spinner drives the busy animation shown in the status bar while a
@@ -11,6 +13,7 @@ import (
11 type spinner struct {13 type spinner struct {
12 frames []string14 frames []string
13 messages []string15 messages []string
16+ interval time.Duration
14 startedAt time.Time17 startedAt time.Time
15 msgIdx int18 msgIdx int
16 19
@@ -20,63 +23,30 @@ type spinner struct {
20 fixedMsg string23 fixedMsg string
21 }24 }
22 25
23-// funnyWorkingLines is the rotating text. Kept deliberately short so it26+// newSpinner constructs a fresh spinner.
24-// fits next to the token counter on narrow terminals. A handful of27+func newSpinner(th tui.Theme) *spinner {
25-// craftsman-style aphorisms are folded in among the irreverent lines28+ s := &spinner{}
26-// for a bit of contrast — the rotation never feels too uniform.29+ s.Configure(th)
27-var funnyWorkingLines = []string{30+ return s
28- "thinking",
29- "reticulating splines",
30- "bribing the tokenizer",
31- "asking the rubber duck",
32- "summoning daemons",
33- "consulting the oracle",
34- "herding tokens",
35- "compiling excuses",
36- "poking the model",
37- "negotiating with rate limits",
38- "picking a fight with syntax",
39- "reading between the bits",
40- "tasting the semicolons",
41- "pretending to understand the code",
42- "petting the cache",
43- "drafting clever replies",
44- "warming up the GPU choir",
45- "arguing with a stack trace",
46- "googling the answer (not really)",
47- "rewriting history",
48- "every draft is a stone in the work",
49- "bringing order to the unhewn",
50- "finding the load-bearing measure",
51- "where clarity grows, work grows lighter",
52- "every correction serves the work",
53-}
54-
55-// spinnerFrames is the cli-spinners "dots3" preset — a 10-frame
56-// single-cell braille spinner that reads as a small box of dots
57-// rotating around its perimeter. Source:
58-// https://github.com/sindresorhus/cli-spinners (MIT). Used at the
59-// preset's recommended 80ms per step.
60-var spinnerFrames = []string{
61- "⠋",
62- "⠙",
63- "⠚",
64- "⠞",
65- "⠖",
66- "⠦",
67- "⠴",
68- "⠲",
69- "⠳",
70- "⠓",
71 }31 }
72 32
73-// newSpinner constructs a fresh spinner.33+func (s *spinner) Configure(th tui.Theme) {
74-func newSpinner() *spinner {34+ s.frames = append([]string(nil), th.SpinnerFrames...)
75- s := &spinner{35+ if len(s.frames) == 0 {
76- frames: spinnerFrames,36+ s.frames = []string{"⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"}
77- messages: funnyWorkingLines,37+ }
38+ s.messages = append([]string(nil), th.SpinnerMessages...)
39+ if len(s.messages) == 0 {
40+ s.messages = []string{"thinking"}
41+ }
42+ interval := th.SpinnerIntervalMS
43+ if interval <= 0 {
44+ interval = 80
45+ }
46+ s.interval = time.Duration(interval) * time.Millisecond
47+ if s.msgIdx >= len(s.messages) {
48+ s.msgIdx = 0
78 }49 }
79- return s
80 }50 }
81 51
82 // Start resets the spinner to the beginning of its animation and52 // Start resets the spinner to the beginning of its animation and
@@ -89,6 +59,9 @@ func newSpinner() *spinner {
89 // still keeps the set fresh over a session.59 // still keeps the set fresh over a session.
90 func (s *spinner) Start() {60 func (s *spinner) Start() {
91 s.startedAt = time.Now()61 s.startedAt = time.Now()
62+ if len(s.messages) == 0 {
63+ s.messages = []string{"thinking"}
64+ }
92 s.msgIdx = rand.Intn(len(s.messages))65 s.msgIdx = rand.Intn(len(s.messages))
93 s.fixedMsg = ""66 s.fixedMsg = ""
94 }67 }
@@ -101,14 +74,19 @@ func (s *spinner) StartFixed(msg string) {
101 }74 }
102 75
103 // Frame returns the current spinner glyph for the running animation.76 // Frame returns the current spinner glyph for the running animation.
104-//
105-// 80ms per step matches the dots3 preset's recommended interval.
106 func (s *spinner) Frame() string {77 func (s *spinner) Frame() string {
78+ if len(s.frames) == 0 {
79+ return ""
80+ }
107 if s.startedAt.IsZero() {81 if s.startedAt.IsZero() {
108 return s.frames[0]82 return s.frames[0]
109 }83 }
84+ interval := s.interval
85+ if interval <= 0 {
86+ interval = 80 * time.Millisecond
87+ }
110 elapsed := time.Since(s.startedAt)88 elapsed := time.Since(s.startedAt)
111- idx := int(elapsed/(80*time.Millisecond)) % len(s.frames)89+ idx := int(elapsed/interval) % len(s.frames)
112 return s.frames[idx]90 return s.frames[idx]
113 }91 }
114 92
@@ -120,6 +98,12 @@ func (s *spinner) Message() string {
120 if s.fixedMsg != "" {98 if s.fixedMsg != "" {
121 return s.fixedMsg99 return s.fixedMsg
122 }100 }
101+ if len(s.messages) == 0 {
102+ return "thinking"
103+ }
104+ if s.msgIdx < 0 || s.msgIdx >= len(s.messages) {
105+ s.msgIdx = 0
106+ }
123 return s.messages[s.msgIdx]107 return s.messages[s.msgIdx]
124 }108 }
125 109
M packages/agent/modes/swarm_dialog.go +3 -1
@@ -1160,8 +1160,10 @@ func (d *swarmDialog) appendTranscriptEditor(out []string, th tui.Theme, width i
11601160 // chat's busy line.
11611161 if agentIsBusy(a) {
11621162 if d.transcriptSpin == nil {
1163- d.transcriptSpin = newSpinner()
1163+ d.transcriptSpin = newSpinner(th)
11641164 d.transcriptSpin.Start()
1165+ } else {
1166+ d.transcriptSpin.Configure(th)
11651167 }
11661168 out = append(out, "")
11671169 prefix := fmt.Sprintf("%s %s, %s",
@@ -1160,8 +1160,10 @@ func (d *swarmDialog) appendTranscriptEditor(out []string, th tui.Theme, width i
1160 // chat's busy line.1160 // chat's busy line.
1161 if agentIsBusy(a) {1161 if agentIsBusy(a) {
1162 if d.transcriptSpin == nil {1162 if d.transcriptSpin == nil {
1163- d.transcriptSpin = newSpinner()1163+ d.transcriptSpin = newSpinner(th)
1164 d.transcriptSpin.Start()1164 d.transcriptSpin.Start()
1165+ } else {
1166+ d.transcriptSpin.Configure(th)
1165 }1167 }
1166 out = append(out, "")1168 out = append(out, "")
1167 prefix := fmt.Sprintf("%s %s, %s",1169 prefix := fmt.Sprintf("%s %s, %s",
M packages/agent/settings_store.go +12 -0
@@ -31,6 +31,18 @@ func (configSettingsStore) SetReasoning(level string) error {
3131 return SaveConfig(cfg)
3232 }
3333
34+func (configSettingsStore) SetTheme(name string) error {
35+ cfg, err := LoadConfig()
36+ if err != nil {
37+ return err
38+ }
39+ if name == "auto" {
40+ name = ""
41+ }
42+ cfg.Theme = name
43+ return SaveConfig(cfg)
44+}
45+
3446 // AutoSwarmEnabled reads the current auto-swarm flag from config.
3547 // Used by the swarm_spawn tool at call time to gate execution.
3648 func AutoSwarmEnabled() bool {
@@ -31,6 +31,18 @@ func (configSettingsStore) SetReasoning(level string) error {
31 return SaveConfig(cfg)31 return SaveConfig(cfg)
32 }32 }
33 33
34+func (configSettingsStore) SetTheme(name string) error {
35+ cfg, err := LoadConfig()
36+ if err != nil {
37+ return err
38+ }
39+ if name == "auto" {
40+ name = ""
41+ }
42+ cfg.Theme = name
43+ return SaveConfig(cfg)
44+}
45+
34 // AutoSwarmEnabled reads the current auto-swarm flag from config.46 // AutoSwarmEnabled reads the current auto-swarm flag from config.
35 // Used by the swarm_spawn tool at call time to gate execution.47 // Used by the swarm_spawn tool at call time to gate execution.
36 func AutoSwarmEnabled() bool {48 func AutoSwarmEnabled() bool {
A packages/agent/skills/builtin/write-zot-themes/SKILL.md +340 -0
@@ -0,0 +1,340 @@
1+---
2+name: write-zot-themes
3+description: Help the user create, install, or package zot themes, including theme-only extensions.
4+---
5+
6+# Writing zot themes
7+
8+Use this skill when the user asks for help creating, editing,
9+installing, debugging, or packaging a zot color theme. Read this skill
10+before generating a theme file or advising on theme extension layout.
11+
12+## What a zot theme is
13+
14+A zot theme is a JSON file that overrides any subset of zot's built-in
15+light/dark theme values. Theme files are intentionally permissive:
16+nothing is required. A file may contain only colors, only spinner
17+settings, only syntax colors, or only metadata. Missing values always
18+inherit from the built-in detected light/dark default.
19+
20+User themes are discovered from:
21+
22+```text
23+$ZOT_HOME/themes/*.json
24+```
25+
26+Extension themes are discovered in-place from loaded extension dirs:
27+
28+```text
29+$ZOT_HOME/extensions/<extension>/theme.json
30+$ZOT_HOME/extensions/<extension>/themes/theme.json
31+<project>/.zot/extensions/<extension>/theme.json
32+<project>/.zot/extensions/<extension>/themes/theme.json
33+```
34+
35+zot does **not** copy extension themes into `$ZOT_HOME/themes`; extension
36+owned themes stay in the extension directory. The settings picker stores
37+an absolute path for extension-owned themes and loads that file directly.
38+
39+## Minimal examples
40+
41+### Empty / metadata-only theme
42+
43+Valid; inherits all colors/spinner/syntax from zot defaults.
44+
45+```json
46+{
47+ "name": "my-theme",
48+ "description": "Metadata only; all visuals inherit zot defaults."
49+}
50+```
51+
52+### Change one color for both light and dark
53+
54+```json
55+{
56+ "name": "pink-accent",
57+ "colors": {
58+ "accent": 204
59+ }
60+}
61+```
62+
63+### Change one color per mode
64+
65+```json
66+{
67+ "name": "split-accent",
68+ "colors": {
69+ "dark": { "accent": 204 },
70+ "light": { "accent": 161 }
71+ }
72+}
73+```
74+
75+### Spinner-only theme
76+
77+Top-level spinner overrides are valid and apply to both modes.
78+
79+```json
80+{
81+ "name": "custom-spinner",
82+ "description": "Only changes the busy spinner.",
83+ "spinner_frames": ["◢", "◣", "◤", "◥"],
84+ "spinner_messages": ["working"],
85+ "spinner_interval_ms": 120
86+}
87+```
88+
89+### Dark-only theme also works in light terminals
90+
91+If `colors.light` is missing, zot applies `colors.dark` overrides on
92+top of the built-in light default when running in light mode. The
93+inverse is also true: if dark is missing but light exists, light
94+settings are used on dark defaults.
95+
96+```json
97+{
98+ "name": "custom-spinner",
99+ "description": "An alternative spinner for zot that only displays a single spinner text.",
100+ "colors": {
101+ "dark": {
102+ "spinner_frames": ["◢", "◣", "◤", "◥"],
103+ "spinner_messages": ["working"],
104+ "spinner_interval_ms": 120
105+ }
106+ }
107+}
108+```
109+
110+## Full theme shape
111+
112+All fields below are optional.
113+
114+```json
115+{
116+ "name": "my-theme",
117+ "description": "Shown in /settings → color theme.",
118+ "color_descriptions": {
119+ "accent": "Optional documentation for humans. zot ignores this object."
120+ },
121+ "colors": {
122+ "dark": {
123+ "fg": 253,
124+ "muted": 244,
125+ "accent": 111,
126+ "user": 180,
127+ "user_bubble_bg": "#42454b",
128+ "user_bubble_fg": 248,
129+ "assistant": 117,
130+ "tool": 114,
131+ "tool_out": 245,
132+ "error": 203,
133+ "warning": 214,
134+ "spinner": 183,
135+ "selection_bg": 24,
136+ "selection_fg": 231,
137+ "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"],
138+ "spinner_messages": ["thinking", "working"],
139+ "spinner_interval_ms": 80,
140+ "syntax_base_style": "monokai",
141+ "syntax": {
142+ "keyword": "#81a1c1 bold",
143+ "keyword_constant": "#81a1c1",
144+ "keyword_declaration": "#81a1c1",
145+ "keyword_namespace": "#81a1c1",
146+ "keyword_reserved": "#81a1c1 bold",
147+ "keyword_type": "#88c0d0",
148+ "name_builtin": "#88c0d0",
149+ "name_function": "#8fbcbb",
150+ "name_class": "#a3be8c bold",
151+ "name_decorator": "#b48ead",
152+ "literal_string": "#a3be8c",
153+ "literal_string_escape": "#bf616a",
154+ "literal_number": "#d08770",
155+ "comment": "#616e88 italic",
156+ "comment_preproc": "#b48ead",
157+ "operator": "#eceff4",
158+ "punctuation": "#d8dee9",
159+ "text": "#e5e9f0"
160+ }
161+ },
162+ "light": {
163+ "fg": 236,
164+ "muted": 244,
165+ "accent": 33
166+ }
167+ }
168+}
169+```
170+
171+You may also put overrides directly at the top level, or directly
172+under `colors`, when they should apply to both modes:
173+
174+```json
175+{
176+ "name": "tiny",
177+ "accent": 204,
178+ "colors": {
179+ "spinner_messages": ["shipping"]
180+ }
181+}
182+```
183+
184+## Color fields
185+
186+- `fg` — default foreground text.
187+- `muted` — secondary text, dividers, gutters, inactive hints.
188+- `accent` — prompt bar, bullets, links, headings, active markers.
189+- `user` — user role label color; mostly compatibility.
190+- `user_bubble_bg` — background behind user message rows.
191+- `user_bubble_fg` — foreground inside user message rows.
192+- `assistant` — assistant/zot accent and spinner text.
193+- `tool` — tool names, success marks, diff additions.
194+- `tool_out` — plain tool-output text.
195+- `error` — errors, refused calls, diff deletions.
196+- `warning` — warnings and high context-usage state.
197+- `spinner` — reserved spinner color slot.
198+- `selection_bg` — highlighted row background.
199+- `selection_fg` — highlighted row foreground.
200+
201+Most color fields are xterm-256 indexes (`0`–`255`).
202+
203+`user_bubble_bg` supports richer terminal color forms:
204+
205+```json
206+254
207+"#42454b"
208+{ "mode": "256", "index": 254 }
209+{ "mode": "ansi", "index": 100 }
210+{ "mode": "rgb", "r": 66, "g": 69, "b": 75 }
211+```
212+
213+## Spinner fields
214+
215+Spinner settings can appear at top level, under `colors`, or under
216+`colors.dark` / `colors.light`.
217+
218+- `spinner_frames` — list of frame strings. Use single-cell glyphs
219+ when possible so status-bar alignment stays clean.
220+- `spinner_messages` — list of messages; zot chooses one per turn.
221+- `spinner_interval_ms` — frame interval in milliseconds; must be
222+ positive. Missing/invalid falls back to 80ms.
223+
224+Example:
225+
226+```json
227+{
228+ "spinner_frames": ["◐", "◓", "◑", "◒"],
229+ "spinner_messages": ["shipping pixels", "warming edge cache"],
230+ "spinner_interval_ms": 120
231+}
232+```
233+
234+## Syntax fields
235+
236+Syntax highlighting uses Chroma style entries. Values may include
237+attributes after the color, such as `bold`, `italic`, or `underline`.
238+
239+Common fields:
240+
241+- `keyword`
242+- `keyword_constant`
243+- `keyword_declaration`
244+- `keyword_namespace`
245+- `keyword_reserved`
246+- `keyword_type`
247+- `name_builtin`
248+- `name_function`
249+- `name_class`
250+- `name_decorator`
251+- `literal_string`
252+- `literal_string_escape`
253+- `literal_number`
254+- `comment`
255+- `comment_preproc`
256+- `operator`
257+- `punctuation`
258+- `text`
259+
260+Example:
261+
262+```json
263+{
264+ "colors": {
265+ "dark": {
266+ "syntax_base_style": "monokai",
267+ "syntax": {
268+ "keyword": "#f05b8d",
269+ "name_function": "#b675f1",
270+ "literal_string": "#58c760",
271+ "comment": "#a1a1a1 italic"
272+ }
273+ }
274+ }
275+}
276+```
277+
278+## Installing a user theme
279+
280+```bash
281+mkdir -p "$ZOT_HOME/themes"
282+cp my-theme.json "$ZOT_HOME/themes/my-theme.json"
283+```
284+
285+Then open `/settings` and choose **color theme**. zot switches theme
286+immediately and persists the selection in `$ZOT_HOME/config.json`.
287+
288+If a selected theme file is deleted, zot resets the setting to the
289+built-in auto/default theme.
290+
291+## Theme-only extensions
292+
293+A zot extension can exist only to ship a theme. No slash command,
294+subprocess, or executable is required when the extension contains a
295+valid theme file.
296+
297+Layout:
298+
299+```text
300+$ZOT_HOME/extensions/my-theme-extension/
301+├── extension.json
302+└── theme.json
303+```
304+
305+or:
306+
307+```text
308+$ZOT_HOME/extensions/my-theme-extension/
309+├── extension.json
310+└── themes/
311+ └── theme.json
312+```
313+
314+`extension.json` for a theme-only extension:
315+
316+```json
317+{
318+ "name": "my-theme-extension",
319+ "version": "1.0.0",
320+ "description": "Ships a zot color theme",
321+ "enabled": true
322+}
323+```
324+
325+No `exec` is needed when `theme.json` or `themes/theme.json` exists.
326+If `exec` is present, zot treats it as a normal extension too.
327+
328+In `/settings → color theme`, extension themes show source info in
329+the description, e.g. `from extension my-theme-extension — ...`.
330+
331+## Recommendations
332+
333+- Prefer short descriptions; settings wraps them, but concise is
334+ easier to scan.
335+- Keep spinner frames single-width when possible.
336+- For theme packages, use a unique extension name so the source label
337+ is clear.
338+- For user-editable themes, keep comments out of JSON; JSON comments
339+ are not supported.
340+- Validate with `python3 -m json.tool theme.json` before installing.
@@ -0,0 +1,340 @@
1+---
2+name: write-zot-themes
3+description: Help the user create, install, or package zot themes, including theme-only extensions.
4+---
5+
6+# Writing zot themes
7+
8+Use this skill when the user asks for help creating, editing,
9+installing, debugging, or packaging a zot color theme. Read this skill
10+before generating a theme file or advising on theme extension layout.
11+
12+## What a zot theme is
13+
14+A zot theme is a JSON file that overrides any subset of zot's built-in
15+light/dark theme values. Theme files are intentionally permissive:
16+nothing is required. A file may contain only colors, only spinner
17+settings, only syntax colors, or only metadata. Missing values always
18+inherit from the built-in detected light/dark default.
19+
20+User themes are discovered from:
21+
22+```text
23+$ZOT_HOME/themes/*.json
24+```
25+
26+Extension themes are discovered in-place from loaded extension dirs:
27+
28+```text
29+$ZOT_HOME/extensions/<extension>/theme.json
30+$ZOT_HOME/extensions/<extension>/themes/theme.json
31+<project>/.zot/extensions/<extension>/theme.json
32+<project>/.zot/extensions/<extension>/themes/theme.json
33+```
34+
35+zot does **not** copy extension themes into `$ZOT_HOME/themes`; extension
36+owned themes stay in the extension directory. The settings picker stores
37+an absolute path for extension-owned themes and loads that file directly.
38+
39+## Minimal examples
40+
41+### Empty / metadata-only theme
42+
43+Valid; inherits all colors/spinner/syntax from zot defaults.
44+
45+```json
46+{
47+ "name": "my-theme",
48+ "description": "Metadata only; all visuals inherit zot defaults."
49+}
50+```
51+
52+### Change one color for both light and dark
53+
54+```json
55+{
56+ "name": "pink-accent",
57+ "colors": {
58+ "accent": 204
59+ }
60+}
61+```
62+
63+### Change one color per mode
64+
65+```json
66+{
67+ "name": "split-accent",
68+ "colors": {
69+ "dark": { "accent": 204 },
70+ "light": { "accent": 161 }
71+ }
72+}
73+```
74+
75+### Spinner-only theme
76+
77+Top-level spinner overrides are valid and apply to both modes.
78+
79+```json
80+{
81+ "name": "custom-spinner",
82+ "description": "Only changes the busy spinner.",
83+ "spinner_frames": ["◢", "◣", "◤", "◥"],
84+ "spinner_messages": ["working"],
85+ "spinner_interval_ms": 120
86+}
87+```
88+
89+### Dark-only theme also works in light terminals
90+
91+If `colors.light` is missing, zot applies `colors.dark` overrides on
92+top of the built-in light default when running in light mode. The
93+inverse is also true: if dark is missing but light exists, light
94+settings are used on dark defaults.
95+
96+```json
97+{
98+ "name": "custom-spinner",
99+ "description": "An alternative spinner for zot that only displays a single spinner text.",
100+ "colors": {
101+ "dark": {
102+ "spinner_frames": ["◢", "◣", "◤", "◥"],
103+ "spinner_messages": ["working"],
104+ "spinner_interval_ms": 120
105+ }
106+ }
107+}
108+```
109+
110+## Full theme shape
111+
112+All fields below are optional.
113+
114+```json
115+{
116+ "name": "my-theme",
117+ "description": "Shown in /settings → color theme.",
118+ "color_descriptions": {
119+ "accent": "Optional documentation for humans. zot ignores this object."
120+ },
121+ "colors": {
122+ "dark": {
123+ "fg": 253,
124+ "muted": 244,
125+ "accent": 111,
126+ "user": 180,
127+ "user_bubble_bg": "#42454b",
128+ "user_bubble_fg": 248,
129+ "assistant": 117,
130+ "tool": 114,
131+ "tool_out": 245,
132+ "error": 203,
133+ "warning": 214,
134+ "spinner": 183,
135+ "selection_bg": 24,
136+ "selection_fg": 231,
137+ "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"],
138+ "spinner_messages": ["thinking", "working"],
139+ "spinner_interval_ms": 80,
140+ "syntax_base_style": "monokai",
141+ "syntax": {
142+ "keyword": "#81a1c1 bold",
143+ "keyword_constant": "#81a1c1",
144+ "keyword_declaration": "#81a1c1",
145+ "keyword_namespace": "#81a1c1",
146+ "keyword_reserved": "#81a1c1 bold",
147+ "keyword_type": "#88c0d0",
148+ "name_builtin": "#88c0d0",
149+ "name_function": "#8fbcbb",
150+ "name_class": "#a3be8c bold",
151+ "name_decorator": "#b48ead",
152+ "literal_string": "#a3be8c",
153+ "literal_string_escape": "#bf616a",
154+ "literal_number": "#d08770",
155+ "comment": "#616e88 italic",
156+ "comment_preproc": "#b48ead",
157+ "operator": "#eceff4",
158+ "punctuation": "#d8dee9",
159+ "text": "#e5e9f0"
160+ }
161+ },
162+ "light": {
163+ "fg": 236,
164+ "muted": 244,
165+ "accent": 33
166+ }
167+ }
168+}
169+```
170+
171+You may also put overrides directly at the top level, or directly
172+under `colors`, when they should apply to both modes:
173+
174+```json
175+{
176+ "name": "tiny",
177+ "accent": 204,
178+ "colors": {
179+ "spinner_messages": ["shipping"]
180+ }
181+}
182+```
183+
184+## Color fields
185+
186+- `fg` — default foreground text.
187+- `muted` — secondary text, dividers, gutters, inactive hints.
188+- `accent` — prompt bar, bullets, links, headings, active markers.
189+- `user` — user role label color; mostly compatibility.
190+- `user_bubble_bg` — background behind user message rows.
191+- `user_bubble_fg` — foreground inside user message rows.
192+- `assistant` — assistant/zot accent and spinner text.
193+- `tool` — tool names, success marks, diff additions.
194+- `tool_out` — plain tool-output text.
195+- `error` — errors, refused calls, diff deletions.
196+- `warning` — warnings and high context-usage state.
197+- `spinner` — reserved spinner color slot.
198+- `selection_bg` — highlighted row background.
199+- `selection_fg` — highlighted row foreground.
200+
201+Most color fields are xterm-256 indexes (`0`–`255`).
202+
203+`user_bubble_bg` supports richer terminal color forms:
204+
205+```json
206+254
207+"#42454b"
208+{ "mode": "256", "index": 254 }
209+{ "mode": "ansi", "index": 100 }
210+{ "mode": "rgb", "r": 66, "g": 69, "b": 75 }
211+```
212+
213+## Spinner fields
214+
215+Spinner settings can appear at top level, under `colors`, or under
216+`colors.dark` / `colors.light`.
217+
218+- `spinner_frames` — list of frame strings. Use single-cell glyphs
219+ when possible so status-bar alignment stays clean.
220+- `spinner_messages` — list of messages; zot chooses one per turn.
221+- `spinner_interval_ms` — frame interval in milliseconds; must be
222+ positive. Missing/invalid falls back to 80ms.
223+
224+Example:
225+
226+```json
227+{
228+ "spinner_frames": ["◐", "◓", "◑", "◒"],
229+ "spinner_messages": ["shipping pixels", "warming edge cache"],
230+ "spinner_interval_ms": 120
231+}
232+```
233+
234+## Syntax fields
235+
236+Syntax highlighting uses Chroma style entries. Values may include
237+attributes after the color, such as `bold`, `italic`, or `underline`.
238+
239+Common fields:
240+
241+- `keyword`
242+- `keyword_constant`
243+- `keyword_declaration`
244+- `keyword_namespace`
245+- `keyword_reserved`
246+- `keyword_type`
247+- `name_builtin`
248+- `name_function`
249+- `name_class`
250+- `name_decorator`
251+- `literal_string`
252+- `literal_string_escape`
253+- `literal_number`
254+- `comment`
255+- `comment_preproc`
256+- `operator`
257+- `punctuation`
258+- `text`
259+
260+Example:
261+
262+```json
263+{
264+ "colors": {
265+ "dark": {
266+ "syntax_base_style": "monokai",
267+ "syntax": {
268+ "keyword": "#f05b8d",
269+ "name_function": "#b675f1",
270+ "literal_string": "#58c760",
271+ "comment": "#a1a1a1 italic"
272+ }
273+ }
274+ }
275+}
276+```
277+
278+## Installing a user theme
279+
280+```bash
281+mkdir -p "$ZOT_HOME/themes"
282+cp my-theme.json "$ZOT_HOME/themes/my-theme.json"
283+```
284+
285+Then open `/settings` and choose **color theme**. zot switches theme
286+immediately and persists the selection in `$ZOT_HOME/config.json`.
287+
288+If a selected theme file is deleted, zot resets the setting to the
289+built-in auto/default theme.
290+
291+## Theme-only extensions
292+
293+A zot extension can exist only to ship a theme. No slash command,
294+subprocess, or executable is required when the extension contains a
295+valid theme file.
296+
297+Layout:
298+
299+```text
300+$ZOT_HOME/extensions/my-theme-extension/
301+├── extension.json
302+└── theme.json
303+```
304+
305+or:
306+
307+```text
308+$ZOT_HOME/extensions/my-theme-extension/
309+├── extension.json
310+└── themes/
311+ └── theme.json
312+```
313+
314+`extension.json` for a theme-only extension:
315+
316+```json
317+{
318+ "name": "my-theme-extension",
319+ "version": "1.0.0",
320+ "description": "Ships a zot color theme",
321+ "enabled": true
322+}
323+```
324+
325+No `exec` is needed when `theme.json` or `themes/theme.json` exists.
326+If `exec` is present, zot treats it as a normal extension too.
327+
328+In `/settings → color theme`, extension themes show source info in
329+the description, e.g. `from extension my-theme-extension — ...`.
330+
331+## Recommendations
332+
333+- Prefer short descriptions; settings wraps them, but concise is
334+ easier to scan.
335+- Keep spinner frames single-width when possible.
336+- For theme packages, use a unique extension name so the source label
337+ is clear.
338+- For user-editable themes, keep comments out of JSON; JSON comments
339+ are not supported.
340+- Validate with `python3 -m json.tool theme.json` before installing.
M packages/tui/highlight.go +80 -52
@@ -21,16 +21,22 @@ import (
2121 // builder (which runs on every redraw) don't re-tokenise. Cache is
2222 // bounded and evicts oldest entries past its cap.
2323 func HighlightCode(src, lang string) []string {
24- if out, ok := highlightCache.lookup(lang, src); ok {
24+ return Dark.HighlightCode(src, lang)
25+}
26+
27+// HighlightCode syntax-colors src using this theme's syntax palette.
28+func (th Theme) HighlightCode(src, lang string) []string {
29+ styleKey := th.syntaxKey()
30+ if out, ok := highlightCache.lookup(styleKey, lang, src); ok {
2531 return out
2632 }
2733 lexer := chooseLexer(lang)
2834 if lexer == nil {
2935 out := strings.Split(src, "\n")
30- highlightCache.store(lang, src, out)
36+ highlightCache.store(styleKey, lang, src, out)
3137 return out
3238 }
33- style := zotChromaStyle
39+ style := th.chromaStyle()
3440 formatter := formatters.Get("terminal256")
3541 if formatter == nil {
3642 return strings.Split(src, "\n")
@@ -39,17 +45,17 @@ func HighlightCode(src, lang string) []string {
3945 iterator, err := lexer.Tokenise(nil, src)
4046 if err != nil {
4147 out := strings.Split(src, "\n")
42- highlightCache.store(lang, src, out)
48+ highlightCache.store(styleKey, lang, src, out)
4349 return out
4450 }
4551 var buf bytes.Buffer
4652 if err := formatter.Format(&buf, style, iterator); err != nil {
4753 out := strings.Split(src, "\n")
48- highlightCache.store(lang, src, out)
54+ highlightCache.store(styleKey, lang, src, out)
4955 return out
5056 }
5157 out := strings.Split(strings.TrimRight(stripANSIBackgrounds(buf.String()), "\n"), "\n")
52- highlightCache.store(lang, src, out)
58+ highlightCache.store(styleKey, lang, src, out)
5359 return out
5460 }
5561
@@ -64,24 +70,24 @@ type highlightResultCache struct {
6470 order []string
6571 }
6672
67-func (c *highlightResultCache) key(lang, src string) string {
68- return lang + "\x00" + src
73+func (c *highlightResultCache) key(styleKey, lang, src string) string {
74+ return styleKey + "\x00" + lang + "\x00" + src
6975 }
7076
71-func (c *highlightResultCache) lookup(lang, src string) ([]string, bool) {
77+func (c *highlightResultCache) lookup(styleKey, lang, src string) ([]string, bool) {
7278 c.mu.Lock()
7379 defer c.mu.Unlock()
74- v, ok := c.data[c.key(lang, src)]
80+ v, ok := c.data[c.key(styleKey, lang, src)]
7581 return v, ok
7682 }
7783
78-func (c *highlightResultCache) store(lang, src string, out []string) {
84+func (c *highlightResultCache) store(styleKey, lang, src string, out []string) {
7985 c.mu.Lock()
8086 defer c.mu.Unlock()
8187 if c.data == nil {
8288 c.data = make(map[string][]string)
8389 }
84- k := c.key(lang, src)
90+ k := c.key(styleKey, lang, src)
8591 if _, ok := c.data[k]; !ok {
8692 c.order = append(c.order, k)
8793 }
@@ -184,46 +190,43 @@ func chooseLexer(lang string) chroma.Lexer {
184190 return nil
185191 }
186192
187-// zotChromaStyle is a terminal style tuned for zot's dark palette.
188-// Built lazily so the cost is paid once per process.
189-var zotChromaStyle = func() *chroma.Style {
190- chromaStyleOnce.Do(func() {
191- chromaStyleCached = buildZotStyle()
192- })
193- return chromaStyleCached
194-}()
195-
196-var (
197- chromaStyleOnce sync.Once
198- chromaStyleCached *chroma.Style
199-)
200-
201-// buildZotStyle returns a chroma style with colors that match zot's
202-// theme: accent blue for keywords, green for strings, muted grey for
203-// comments, user-tan for numbers. Falls back to chroma's bundled
204-// "fruity" style if the builder fails for any reason.
205-func buildZotStyle() *chroma.Style {
206- builder := styles.Get("monokai").Builder()
207- // Override the most visible tokens with explicit colors that
208- // read well on a dark terminal.
209- builder.Add(chroma.Keyword, "#81a1c1 bold") // imports, funcs, control flow
210- builder.Add(chroma.KeywordConstant, "#81a1c1") // true, false, null
211- builder.Add(chroma.KeywordDeclaration, "#81a1c1") // const, let, var, type
212- builder.Add(chroma.KeywordNamespace, "#81a1c1") // import, from, package
213- builder.Add(chroma.KeywordReserved, "#81a1c1 bold")
214- builder.Add(chroma.KeywordType, "#88c0d0") // string, int, bool
215- builder.Add(chroma.NameBuiltin, "#88c0d0")
216- builder.Add(chroma.NameFunction, "#8fbcbb")
217- builder.Add(chroma.NameClass, "#a3be8c bold")
218- builder.Add(chroma.NameDecorator, "#b48ead")
219- builder.Add(chroma.LiteralString, "#a3be8c") // strings
220- builder.Add(chroma.LiteralStringEscape, "#bf616a")
221- builder.Add(chroma.LiteralNumber, "#d08770")
222- builder.Add(chroma.Comment, "#616e88 italic")
223- builder.Add(chroma.CommentPreproc, "#b48ead")
224- builder.Add(chroma.Operator, "#eceff4")
225- builder.Add(chroma.Punctuation, "#d8dee9")
226- builder.Add(chroma.Text, "#e5e9f0")
193+// chromaStyle builds a syntax style from the theme. Falls back to
194+// chroma's bundled fallback style if the configured base style or
195+// overrides cannot be built.
196+func (th Theme) chromaStyle() *chroma.Style {
197+ base := th.SyntaxBaseStyle
198+ if base == "" {
199+ base = "monokai"
200+ }
201+ style := styles.Get(base)
202+ if style == nil {
203+ style = styles.Fallback
204+ }
205+ builder := style.Builder()
206+ add := func(tt chroma.TokenType, entry string) {
207+ if strings.TrimSpace(entry) != "" {
208+ builder.Add(tt, entry)
209+ }
210+ }
211+ syn := th.Syntax
212+ add(chroma.Keyword, syn.Keyword)
213+ add(chroma.KeywordConstant, syn.KeywordConstant)
214+ add(chroma.KeywordDeclaration, syn.KeywordDeclaration)
215+ add(chroma.KeywordNamespace, syn.KeywordNamespace)
216+ add(chroma.KeywordReserved, syn.KeywordReserved)
217+ add(chroma.KeywordType, syn.KeywordType)
218+ add(chroma.NameBuiltin, syn.NameBuiltin)
219+ add(chroma.NameFunction, syn.NameFunction)
220+ add(chroma.NameClass, syn.NameClass)
221+ add(chroma.NameDecorator, syn.NameDecorator)
222+ add(chroma.LiteralString, syn.LiteralString)
223+ add(chroma.LiteralStringEscape, syn.LiteralStringEscape)
224+ add(chroma.LiteralNumber, syn.LiteralNumber)
225+ add(chroma.Comment, syn.Comment)
226+ add(chroma.CommentPreproc, syn.CommentPreproc)
227+ add(chroma.Operator, syn.Operator)
228+ add(chroma.Punctuation, syn.Punctuation)
229+ add(chroma.Text, syn.Text)
227230 builder.Add(chroma.Background, " bg:")
228231
229232 s, err := builder.Build()
@@ -233,6 +236,31 @@ func buildZotStyle() *chroma.Style {
233236 return s
234237 }
235238
239+func (th Theme) syntaxKey() string {
240+ syn := th.Syntax
241+ return strings.Join([]string{
242+ th.SyntaxBaseStyle,
243+ syn.Keyword,
244+ syn.KeywordConstant,
245+ syn.KeywordDeclaration,
246+ syn.KeywordNamespace,
247+ syn.KeywordReserved,
248+ syn.KeywordType,
249+ syn.NameBuiltin,
250+ syn.NameFunction,
251+ syn.NameClass,
252+ syn.NameDecorator,
253+ syn.LiteralString,
254+ syn.LiteralStringEscape,
255+ syn.LiteralNumber,
256+ syn.Comment,
257+ syn.CommentPreproc,
258+ syn.Operator,
259+ syn.Punctuation,
260+ syn.Text,
261+ }, "\x1f")
262+}
263+
236264 // extLang maps file extensions to chroma lexer names. Only
237265 // extensions that chroma supports are included.
238266 var extLang = map[string]string{
@@ -21,16 +21,22 @@ import (
21 // builder (which runs on every redraw) don't re-tokenise. Cache is21 // builder (which runs on every redraw) don't re-tokenise. Cache is
22 // bounded and evicts oldest entries past its cap.22 // bounded and evicts oldest entries past its cap.
23 func HighlightCode(src, lang string) []string {23 func HighlightCode(src, lang string) []string {
24- if out, ok := highlightCache.lookup(lang, src); ok {24+ return Dark.HighlightCode(src, lang)
25+}
26+
27+// HighlightCode syntax-colors src using this theme's syntax palette.
28+func (th Theme) HighlightCode(src, lang string) []string {
29+ styleKey := th.syntaxKey()
30+ if out, ok := highlightCache.lookup(styleKey, lang, src); ok {
25 return out31 return out
26 }32 }
27 lexer := chooseLexer(lang)33 lexer := chooseLexer(lang)
28 if lexer == nil {34 if lexer == nil {
29 out := strings.Split(src, "\n")35 out := strings.Split(src, "\n")
30- highlightCache.store(lang, src, out)36+ highlightCache.store(styleKey, lang, src, out)
31 return out37 return out
32 }38 }
33- style := zotChromaStyle39+ style := th.chromaStyle()
34 formatter := formatters.Get("terminal256")40 formatter := formatters.Get("terminal256")
35 if formatter == nil {41 if formatter == nil {
36 return strings.Split(src, "\n")42 return strings.Split(src, "\n")
@@ -39,17 +45,17 @@ func HighlightCode(src, lang string) []string {
39 iterator, err := lexer.Tokenise(nil, src)45 iterator, err := lexer.Tokenise(nil, src)
40 if err != nil {46 if err != nil {
41 out := strings.Split(src, "\n")47 out := strings.Split(src, "\n")
42- highlightCache.store(lang, src, out)48+ highlightCache.store(styleKey, lang, src, out)
43 return out49 return out
44 }50 }
45 var buf bytes.Buffer51 var buf bytes.Buffer
46 if err := formatter.Format(&buf, style, iterator); err != nil {52 if err := formatter.Format(&buf, style, iterator); err != nil {
47 out := strings.Split(src, "\n")53 out := strings.Split(src, "\n")
48- highlightCache.store(lang, src, out)54+ highlightCache.store(styleKey, lang, src, out)
49 return out55 return out
50 }56 }
51 out := strings.Split(strings.TrimRight(stripANSIBackgrounds(buf.String()), "\n"), "\n")57 out := strings.Split(strings.TrimRight(stripANSIBackgrounds(buf.String()), "\n"), "\n")
52- highlightCache.store(lang, src, out)58+ highlightCache.store(styleKey, lang, src, out)
53 return out59 return out
54 }60 }
55 61
@@ -64,24 +70,24 @@ type highlightResultCache struct {
64 order []string70 order []string
65 }71 }
66 72
67-func (c *highlightResultCache) key(lang, src string) string {73+func (c *highlightResultCache) key(styleKey, lang, src string) string {
68- return lang + "\x00" + src74+ return styleKey + "\x00" + lang + "\x00" + src
69 }75 }
70 76
71-func (c *highlightResultCache) lookup(lang, src string) ([]string, bool) {77+func (c *highlightResultCache) lookup(styleKey, lang, src string) ([]string, bool) {
72 c.mu.Lock()78 c.mu.Lock()
73 defer c.mu.Unlock()79 defer c.mu.Unlock()
74- v, ok := c.data[c.key(lang, src)]80+ v, ok := c.data[c.key(styleKey, lang, src)]
75 return v, ok81 return v, ok
76 }82 }
77 83
78-func (c *highlightResultCache) store(lang, src string, out []string) {84+func (c *highlightResultCache) store(styleKey, lang, src string, out []string) {
79 c.mu.Lock()85 c.mu.Lock()
80 defer c.mu.Unlock()86 defer c.mu.Unlock()
81 if c.data == nil {87 if c.data == nil {
82 c.data = make(map[string][]string)88 c.data = make(map[string][]string)
83 }89 }
84- k := c.key(lang, src)90+ k := c.key(styleKey, lang, src)
85 if _, ok := c.data[k]; !ok {91 if _, ok := c.data[k]; !ok {
86 c.order = append(c.order, k)92 c.order = append(c.order, k)
87 }93 }
@@ -184,46 +190,43 @@ func chooseLexer(lang string) chroma.Lexer {
184 return nil190 return nil
185 }191 }
186 192
187-// zotChromaStyle is a terminal style tuned for zot's dark palette.193+// chromaStyle builds a syntax style from the theme. Falls back to
188-// Built lazily so the cost is paid once per process.194+// chroma's bundled fallback style if the configured base style or
189-var zotChromaStyle = func() *chroma.Style {195+// overrides cannot be built.
190- chromaStyleOnce.Do(func() {196+func (th Theme) chromaStyle() *chroma.Style {
191- chromaStyleCached = buildZotStyle()197+ base := th.SyntaxBaseStyle
192- })198+ if base == "" {
193- return chromaStyleCached199+ base = "monokai"
194-}()200+ }
195-201+ style := styles.Get(base)
196-var (202+ if style == nil {
197- chromaStyleOnce sync.Once203+ style = styles.Fallback
198- chromaStyleCached *chroma.Style204+ }
199-)205+ builder := style.Builder()
200-206+ add := func(tt chroma.TokenType, entry string) {
201-// buildZotStyle returns a chroma style with colors that match zot's207+ if strings.TrimSpace(entry) != "" {
202-// theme: accent blue for keywords, green for strings, muted grey for208+ builder.Add(tt, entry)
203-// comments, user-tan for numbers. Falls back to chroma's bundled209+ }
204-// "fruity" style if the builder fails for any reason.210+ }
205-func buildZotStyle() *chroma.Style {211+ syn := th.Syntax
206- builder := styles.Get("monokai").Builder()212+ add(chroma.Keyword, syn.Keyword)
207- // Override the most visible tokens with explicit colors that213+ add(chroma.KeywordConstant, syn.KeywordConstant)
208- // read well on a dark terminal.214+ add(chroma.KeywordDeclaration, syn.KeywordDeclaration)
209- builder.Add(chroma.Keyword, "#81a1c1 bold") // imports, funcs, control flow215+ add(chroma.KeywordNamespace, syn.KeywordNamespace)
210- builder.Add(chroma.KeywordConstant, "#81a1c1") // true, false, null216+ add(chroma.KeywordReserved, syn.KeywordReserved)
211- builder.Add(chroma.KeywordDeclaration, "#81a1c1") // const, let, var, type217+ add(chroma.KeywordType, syn.KeywordType)
212- builder.Add(chroma.KeywordNamespace, "#81a1c1") // import, from, package218+ add(chroma.NameBuiltin, syn.NameBuiltin)
213- builder.Add(chroma.KeywordReserved, "#81a1c1 bold")219+ add(chroma.NameFunction, syn.NameFunction)
214- builder.Add(chroma.KeywordType, "#88c0d0") // string, int, bool220+ add(chroma.NameClass, syn.NameClass)
215- builder.Add(chroma.NameBuiltin, "#88c0d0")221+ add(chroma.NameDecorator, syn.NameDecorator)
216- builder.Add(chroma.NameFunction, "#8fbcbb")222+ add(chroma.LiteralString, syn.LiteralString)
217- builder.Add(chroma.NameClass, "#a3be8c bold")223+ add(chroma.LiteralStringEscape, syn.LiteralStringEscape)
218- builder.Add(chroma.NameDecorator, "#b48ead")224+ add(chroma.LiteralNumber, syn.LiteralNumber)
219- builder.Add(chroma.LiteralString, "#a3be8c") // strings225+ add(chroma.Comment, syn.Comment)
220- builder.Add(chroma.LiteralStringEscape, "#bf616a")226+ add(chroma.CommentPreproc, syn.CommentPreproc)
221- builder.Add(chroma.LiteralNumber, "#d08770")227+ add(chroma.Operator, syn.Operator)
222- builder.Add(chroma.Comment, "#616e88 italic")228+ add(chroma.Punctuation, syn.Punctuation)
223- builder.Add(chroma.CommentPreproc, "#b48ead")229+ add(chroma.Text, syn.Text)
224- builder.Add(chroma.Operator, "#eceff4")
225- builder.Add(chroma.Punctuation, "#d8dee9")
226- builder.Add(chroma.Text, "#e5e9f0")
227 builder.Add(chroma.Background, " bg:")230 builder.Add(chroma.Background, " bg:")
228 231
229 s, err := builder.Build()232 s, err := builder.Build()
@@ -233,6 +236,31 @@ func buildZotStyle() *chroma.Style {
233 return s236 return s
234 }237 }
235 238
239+func (th Theme) syntaxKey() string {
240+ syn := th.Syntax
241+ return strings.Join([]string{
242+ th.SyntaxBaseStyle,
243+ syn.Keyword,
244+ syn.KeywordConstant,
245+ syn.KeywordDeclaration,
246+ syn.KeywordNamespace,
247+ syn.KeywordReserved,
248+ syn.KeywordType,
249+ syn.NameBuiltin,
250+ syn.NameFunction,
251+ syn.NameClass,
252+ syn.NameDecorator,
253+ syn.LiteralString,
254+ syn.LiteralStringEscape,
255+ syn.LiteralNumber,
256+ syn.Comment,
257+ syn.CommentPreproc,
258+ syn.Operator,
259+ syn.Punctuation,
260+ syn.Text,
261+ }, "\x1f")
262+}
263+
236 // extLang maps file extensions to chroma lexer names. Only264 // extLang maps file extensions to chroma lexer names. Only
237 // extensions that chroma supports are included.265 // extensions that chroma supports are included.
238 var extLang = map[string]string{266 var extLang = map[string]string{
M packages/tui/markdown.go +1 -1
@@ -46,7 +46,7 @@ func RenderMarkdown(src string, th Theme, width int) string {
4646 }
4747 code := strings.TrimRight(fenceBuf.String(), "\n")
4848 if fenceLang != "" {
49- for _, l := range HighlightCode(code, fenceLang) {
49+ for _, l := range th.HighlightCode(code, fenceLang) {
5050 out.WriteString(l)
5151 out.WriteString("\n")
5252 }
@@ -46,7 +46,7 @@ func RenderMarkdown(src string, th Theme, width int) string {
46 }46 }
47 code := strings.TrimRight(fenceBuf.String(), "\n")47 code := strings.TrimRight(fenceBuf.String(), "\n")
48 if fenceLang != "" {48 if fenceLang != "" {
49- for _, l := range HighlightCode(code, fenceLang) {49+ for _, l := range th.HighlightCode(code, fenceLang) {
50 out.WriteString(l)50 out.WriteString(l)
51 out.WriteString("\n")51 out.WriteString("\n")
52 }52 }
M packages/tui/theme.go +145 -30
@@ -50,40 +50,143 @@ type Theme struct {
5050 Spinner int // spinner + funny working line
5151 SelectionBG int // background for highlighted rows
5252 SelectionFG int // foreground for highlighted rows
53+
54+ SpinnerFrames []string
55+ SpinnerMessages []string
56+ SpinnerIntervalMS int
57+
58+ SyntaxBaseStyle string
59+ Syntax SyntaxTheme
60+}
61+
62+// SyntaxTheme contains the chroma token colors used by code fences,
63+// file previews, and diffs. Values are chroma style entries, so they
64+// may include attributes after the color (for example "#81a1c1 bold").
65+type SyntaxTheme struct {
66+ Keyword string
67+ KeywordConstant string
68+ KeywordDeclaration string
69+ KeywordNamespace string
70+ KeywordReserved string
71+ KeywordType string
72+ NameBuiltin string
73+ NameFunction string
74+ NameClass string
75+ NameDecorator string
76+ LiteralString string
77+ LiteralStringEscape string
78+ LiteralNumber string
79+ Comment string
80+ CommentPreproc string
81+ Operator string
82+ Punctuation string
83+ Text string
84+}
85+
86+var nordSyntax = SyntaxTheme{
87+ Keyword: "#81a1c1 bold",
88+ KeywordConstant: "#81a1c1",
89+ KeywordDeclaration: "#81a1c1",
90+ KeywordNamespace: "#81a1c1",
91+ KeywordReserved: "#81a1c1 bold",
92+ KeywordType: "#88c0d0",
93+ NameBuiltin: "#88c0d0",
94+ NameFunction: "#8fbcbb",
95+ NameClass: "#a3be8c bold",
96+ NameDecorator: "#b48ead",
97+ LiteralString: "#a3be8c",
98+ LiteralStringEscape: "#bf616a",
99+ LiteralNumber: "#d08770",
100+ Comment: "#616e88 italic",
101+ CommentPreproc: "#b48ead",
102+ Operator: "#eceff4",
103+ Punctuation: "#d8dee9",
104+ Text: "#e5e9f0",
105+}
106+
107+var defaultSpinnerFrames = []string{
108+ "⠋",
109+ "⠙",
110+ "⠚",
111+ "⠞",
112+ "⠖",
113+ "⠦",
114+ "⠴",
115+ "⠲",
116+ "⠳",
117+ "⠓",
118+}
119+
120+var defaultSpinnerMessages = []string{
121+ "thinking",
122+ "reticulating splines",
123+ "bribing the tokenizer",
124+ "asking the rubber duck",
125+ "summoning daemons",
126+ "consulting the oracle",
127+ "herding tokens",
128+ "compiling excuses",
129+ "poking the model",
130+ "negotiating with rate limits",
131+ "picking a fight with syntax",
132+ "reading between the bits",
133+ "tasting the semicolons",
134+ "pretending to understand the code",
135+ "petting the cache",
136+ "drafting clever replies",
137+ "warming up the GPU choir",
138+ "arguing with a stack trace",
139+ "googling the answer (not really)",
140+ "rewriting history",
141+ "every draft is a stone in the work",
142+ "bringing order to the unhewn",
143+ "finding the load-bearing measure",
144+ "where clarity grows, work grows lighter",
145+ "every correction serves the work",
53146 }
54147
55148 var Dark = Theme{
56- FG: 253,
57- Muted: 244,
58- Accent: 111, // soft blue
59- User: 180, // warm tan (unused now that the speaker label is gone, kept for skin compat)
60- UserBubbleBG: ColorRGB(66, 69, 75), // #42454B
61- UserBubbleFG: 248, // slightly lighter grey for readability on #42454B
62- Assistant: 117, // bright cyan — the zot label color
63- Tool: 114, // green
64- ToolOut: 245,
65- Error: 203,
66- Warning: 214,
67- Spinner: 183, // soft purple
68- SelectionBG: 24, // deep blue background
69- SelectionFG: 231, // near-white foreground
149+ FG: 253,
150+ Muted: 244,
151+ Accent: 111, // soft blue
152+ User: 180, // warm tan (unused now that the speaker label is gone, kept for skin compat)
153+ UserBubbleBG: ColorRGB(66, 69, 75), // #42454B
154+ UserBubbleFG: 248, // slightly lighter grey for readability on #42454B
155+ Assistant: 117, // bright cyan — the zot label color
156+ Tool: 114, // green
157+ ToolOut: 245,
158+ Error: 203,
159+ Warning: 214,
160+ Spinner: 183, // soft purple
161+ SelectionBG: 24, // deep blue background
162+ SelectionFG: 231, // near-white foreground
163+ SpinnerFrames: defaultSpinnerFrames,
164+ SpinnerMessages: defaultSpinnerMessages,
165+ SpinnerIntervalMS: 80,
166+ SyntaxBaseStyle: "monokai",
167+ Syntax: nordSyntax,
70168 }
71169
72170 var Light = Theme{
73- FG: 236,
74- Muted: 244,
75- Accent: 33,
76- User: 94,
77- UserBubbleBG: Color256(254), // very pale grey panel behind user rows on light theme
78- UserBubbleFG: 240, // dark grey text, legible on the pale panel
79- Assistant: 31, // deep cyan
80- Tool: 28,
81- ToolOut: 240,
82- Error: 160,
83- Warning: 166,
84- Spinner: 91, // purple
85- SelectionBG: 153, // light blue
86- SelectionFG: 232, // near-black
171+ FG: 236,
172+ Muted: 244,
173+ Accent: 33,
174+ User: 94,
175+ UserBubbleBG: Color256(254), // very pale grey panel behind user rows on light theme
176+ UserBubbleFG: 240, // dark grey text, legible on the pale panel
177+ Assistant: 31, // deep cyan
178+ Tool: 28,
179+ ToolOut: 240,
180+ Error: 160,
181+ Warning: 166,
182+ Spinner: 91, // purple
183+ SelectionBG: 153, // light blue
184+ SelectionFG: 232, // near-black
185+ SpinnerFrames: defaultSpinnerFrames,
186+ SpinnerMessages: defaultSpinnerMessages,
187+ SpinnerIntervalMS: 80,
188+ SyntaxBaseStyle: "monokai",
189+ Syntax: nordSyntax,
87190 }
88191
89192 // FG256 wraps s in foreground color c using ANSI 256-color SGR.
@@ -118,7 +221,19 @@ func (t Theme) AccentBar(c int) string {
118221 // background). The caller is responsible for padding s to the desired
119222 // width; styling alone does not extend the background past content.
120223 func (t Theme) Highlight(s string) string {
121- return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + reset
224+ return t.SelectionStyle() + s + reset
225+}
226+
227+// SelectionStyle returns the SGR prefix for the theme's selected row.
228+func (t Theme) SelectionStyle() string {
229+ return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG)
230+}
231+
232+// SelectionStyleFG returns the SGR prefix for selected-row text with
233+// a custom foreground. Useful for preserving semantic marks inside a
234+// highlighted row without hardcoding escape sequences outside theme.go.
235+func (t Theme) SelectionStyleFG(fg int) string {
236+ return sgrFG(fg) + sgrBG(t.SelectionBG)
122237 }
123238
124239 // PadHighlight styles s and extends the selection background to the
@@ -129,7 +244,7 @@ func (t Theme) PadHighlight(s string, width int) string {
129244 if visible < width {
130245 s += strings.Repeat(" ", width-visible)
131246 }
132- return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + reset
247+ return t.SelectionStyle() + s + reset
133248 }
134249
135250 // UserBubble paints a single user message row with the bubble
@@ -50,40 +50,143 @@ type Theme struct {
50 Spinner int // spinner + funny working line50 Spinner int // spinner + funny working line
51 SelectionBG int // background for highlighted rows51 SelectionBG int // background for highlighted rows
52 SelectionFG int // foreground for highlighted rows52 SelectionFG int // foreground for highlighted rows
53+
54+ SpinnerFrames []string
55+ SpinnerMessages []string
56+ SpinnerIntervalMS int
57+
58+ SyntaxBaseStyle string
59+ Syntax SyntaxTheme
60+}
61+
62+// SyntaxTheme contains the chroma token colors used by code fences,
63+// file previews, and diffs. Values are chroma style entries, so they
64+// may include attributes after the color (for example "#81a1c1 bold").
65+type SyntaxTheme struct {
66+ Keyword string
67+ KeywordConstant string
68+ KeywordDeclaration string
69+ KeywordNamespace string
70+ KeywordReserved string
71+ KeywordType string
72+ NameBuiltin string
73+ NameFunction string
74+ NameClass string
75+ NameDecorator string
76+ LiteralString string
77+ LiteralStringEscape string
78+ LiteralNumber string
79+ Comment string
80+ CommentPreproc string
81+ Operator string
82+ Punctuation string
83+ Text string
84+}
85+
86+var nordSyntax = SyntaxTheme{
87+ Keyword: "#81a1c1 bold",
88+ KeywordConstant: "#81a1c1",
89+ KeywordDeclaration: "#81a1c1",
90+ KeywordNamespace: "#81a1c1",
91+ KeywordReserved: "#81a1c1 bold",
92+ KeywordType: "#88c0d0",
93+ NameBuiltin: "#88c0d0",
94+ NameFunction: "#8fbcbb",
95+ NameClass: "#a3be8c bold",
96+ NameDecorator: "#b48ead",
97+ LiteralString: "#a3be8c",
98+ LiteralStringEscape: "#bf616a",
99+ LiteralNumber: "#d08770",
100+ Comment: "#616e88 italic",
101+ CommentPreproc: "#b48ead",
102+ Operator: "#eceff4",
103+ Punctuation: "#d8dee9",
104+ Text: "#e5e9f0",
105+}
106+
107+var defaultSpinnerFrames = []string{
108+ "⠋",
109+ "⠙",
110+ "⠚",
111+ "⠞",
112+ "⠖",
113+ "⠦",
114+ "⠴",
115+ "⠲",
116+ "⠳",
117+ "⠓",
118+}
119+
120+var defaultSpinnerMessages = []string{
121+ "thinking",
122+ "reticulating splines",
123+ "bribing the tokenizer",
124+ "asking the rubber duck",
125+ "summoning daemons",
126+ "consulting the oracle",
127+ "herding tokens",
128+ "compiling excuses",
129+ "poking the model",
130+ "negotiating with rate limits",
131+ "picking a fight with syntax",
132+ "reading between the bits",
133+ "tasting the semicolons",
134+ "pretending to understand the code",
135+ "petting the cache",
136+ "drafting clever replies",
137+ "warming up the GPU choir",
138+ "arguing with a stack trace",
139+ "googling the answer (not really)",
140+ "rewriting history",
141+ "every draft is a stone in the work",
142+ "bringing order to the unhewn",
143+ "finding the load-bearing measure",
144+ "where clarity grows, work grows lighter",
145+ "every correction serves the work",
53 }146 }
54 147
55 var Dark = Theme{148 var Dark = Theme{
56- FG: 253,149+ FG: 253,
57- Muted: 244,150+ Muted: 244,
58- Accent: 111, // soft blue151+ Accent: 111, // soft blue
59- User: 180, // warm tan (unused now that the speaker label is gone, kept for skin compat)152+ User: 180, // warm tan (unused now that the speaker label is gone, kept for skin compat)
60- UserBubbleBG: ColorRGB(66, 69, 75), // #42454B153+ UserBubbleBG: ColorRGB(66, 69, 75), // #42454B
61- UserBubbleFG: 248, // slightly lighter grey for readability on #42454B154+ UserBubbleFG: 248, // slightly lighter grey for readability on #42454B
62- Assistant: 117, // bright cyan — the zot label color155+ Assistant: 117, // bright cyan — the zot label color
63- Tool: 114, // green156+ Tool: 114, // green
64- ToolOut: 245,157+ ToolOut: 245,
65- Error: 203,158+ Error: 203,
66- Warning: 214,159+ Warning: 214,
67- Spinner: 183, // soft purple160+ Spinner: 183, // soft purple
68- SelectionBG: 24, // deep blue background161+ SelectionBG: 24, // deep blue background
69- SelectionFG: 231, // near-white foreground162+ SelectionFG: 231, // near-white foreground
163+ SpinnerFrames: defaultSpinnerFrames,
164+ SpinnerMessages: defaultSpinnerMessages,
165+ SpinnerIntervalMS: 80,
166+ SyntaxBaseStyle: "monokai",
167+ Syntax: nordSyntax,
70 }168 }
71 169
72 var Light = Theme{170 var Light = Theme{
73- FG: 236,171+ FG: 236,
74- Muted: 244,172+ Muted: 244,
75- Accent: 33,173+ Accent: 33,
76- User: 94,174+ User: 94,
77- UserBubbleBG: Color256(254), // very pale grey panel behind user rows on light theme175+ UserBubbleBG: Color256(254), // very pale grey panel behind user rows on light theme
78- UserBubbleFG: 240, // dark grey text, legible on the pale panel176+ UserBubbleFG: 240, // dark grey text, legible on the pale panel
79- Assistant: 31, // deep cyan177+ Assistant: 31, // deep cyan
80- Tool: 28,178+ Tool: 28,
81- ToolOut: 240,179+ ToolOut: 240,
82- Error: 160,180+ Error: 160,
83- Warning: 166,181+ Warning: 166,
84- Spinner: 91, // purple182+ Spinner: 91, // purple
85- SelectionBG: 153, // light blue183+ SelectionBG: 153, // light blue
86- SelectionFG: 232, // near-black184+ SelectionFG: 232, // near-black
185+ SpinnerFrames: defaultSpinnerFrames,
186+ SpinnerMessages: defaultSpinnerMessages,
187+ SpinnerIntervalMS: 80,
188+ SyntaxBaseStyle: "monokai",
189+ Syntax: nordSyntax,
87 }190 }
88 191
89 // FG256 wraps s in foreground color c using ANSI 256-color SGR.192 // FG256 wraps s in foreground color c using ANSI 256-color SGR.
@@ -118,7 +221,19 @@ func (t Theme) AccentBar(c int) string {
118 // background). The caller is responsible for padding s to the desired221 // background). The caller is responsible for padding s to the desired
119 // width; styling alone does not extend the background past content.222 // width; styling alone does not extend the background past content.
120 func (t Theme) Highlight(s string) string {223 func (t Theme) Highlight(s string) string {
121- return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + reset224+ return t.SelectionStyle() + s + reset
225+}
226+
227+// SelectionStyle returns the SGR prefix for the theme's selected row.
228+func (t Theme) SelectionStyle() string {
229+ return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG)
230+}
231+
232+// SelectionStyleFG returns the SGR prefix for selected-row text with
233+// a custom foreground. Useful for preserving semantic marks inside a
234+// highlighted row without hardcoding escape sequences outside theme.go.
235+func (t Theme) SelectionStyleFG(fg int) string {
236+ return sgrFG(fg) + sgrBG(t.SelectionBG)
122 }237 }
123 238
124 // PadHighlight styles s and extends the selection background to the239 // PadHighlight styles s and extends the selection background to the
@@ -129,7 +244,7 @@ func (t Theme) PadHighlight(s string, width int) string {
129 if visible < width {244 if visible < width {
130 s += strings.Repeat(" ", width-visible)245 s += strings.Repeat(" ", width-visible)
131 }246 }
132- return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + reset247+ return t.SelectionStyle() + s + reset
133 }248 }
134 249
135 // UserBubble paints a single user message row with the bubble250 // UserBubble paints a single user message row with the bubble
A packages/tui/theme_loader.go +499 -0
@@ -0,0 +1,499 @@
1+package tui
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "sort"
10+ "strings"
11+ "time"
12+)
13+
14+// ThemeOption is one selectable theme discovered under $ZOT_HOME/themes.
15+// Value is stored in config.json.
16+type ThemeOption struct {
17+ Value string
18+ Label string
19+ Description string
20+ Path string
21+ Builtin bool
22+}
23+
24+// ThemeFile is the user-editable JSON shape loaded from
25+// $ZOT_HOME/themes/*.json. It carries metadata plus separate overrides
26+// for dark and light terminals.
27+type ThemeFile struct {
28+ Name string `json:"name"`
29+ Description string `json:"description"`
30+ Colors ThemeFileColorModes `json:"colors"`
31+ Overrides ThemeOverrides `json:"-"`
32+}
33+
34+func (tf *ThemeFile) UnmarshalJSON(data []byte) error {
35+ type alias ThemeFile
36+ var a alias
37+ if err := json.Unmarshal(data, &a); err != nil {
38+ return err
39+ }
40+ // Allow a tiny theme file with overrides at the top level, e.g.
41+ // {"spinner_frames":[".","o"],"spinner_messages":["working"]}.
42+ // Metadata fields are ignored by ThemeOverrides because they do not
43+ // have matching json tags.
44+ _ = json.Unmarshal(data, &a.Overrides)
45+ *tf = ThemeFile(a)
46+ return nil
47+}
48+
49+type ThemeFileColorModes struct {
50+ Base ThemeOverrides `json:"-"`
51+ Dark ThemeOverrides `json:"dark"`
52+ Light ThemeOverrides `json:"light"`
53+ HasDark bool `json:"-"`
54+ HasLight bool `json:"-"`
55+}
56+
57+func (m *ThemeFileColorModes) UnmarshalJSON(data []byte) error {
58+ var raw map[string]json.RawMessage
59+ if err := json.Unmarshal(data, &raw); err != nil {
60+ return err
61+ }
62+ if v, ok := raw["dark"]; ok {
63+ m.HasDark = true
64+ _ = json.Unmarshal(v, &m.Dark)
65+ }
66+ if v, ok := raw["light"]; ok {
67+ m.HasLight = true
68+ _ = json.Unmarshal(v, &m.Light)
69+ }
70+ // Also allow colors to directly contain overrides shared by both
71+ // modes, e.g. {"colors":{"accent":204}}.
72+ var base ThemeOverrides
73+ _ = json.Unmarshal(data, &base)
74+ m.Base = base
75+ return nil
76+}
77+
78+// ThemeOverrides is intentionally pointer-based so a theme file can
79+// override only the colors it cares about and inherit the built-in
80+// dark/light defaults for everything else.
81+type ThemeOverrides struct {
82+ FG *int `json:"fg,omitempty"`
83+ Muted *int `json:"muted,omitempty"`
84+ Accent *int `json:"accent,omitempty"`
85+ User *int `json:"user,omitempty"`
86+ UserBubbleBG *TerminalColorValue `json:"user_bubble_bg,omitempty"`
87+ UserBubbleFG *int `json:"user_bubble_fg,omitempty"`
88+ Assistant *int `json:"assistant,omitempty"`
89+ Tool *int `json:"tool,omitempty"`
90+ ToolOut *int `json:"tool_out,omitempty"`
91+ Error *int `json:"error,omitempty"`
92+ Warning *int `json:"warning,omitempty"`
93+ Spinner *int `json:"spinner,omitempty"`
94+ SelectionBG *int `json:"selection_bg,omitempty"`
95+ SelectionFG *int `json:"selection_fg,omitempty"`
96+ SpinnerFrames []string `json:"spinner_frames,omitempty"`
97+ SpinnerMessages []string `json:"spinner_messages,omitempty"`
98+ SpinnerIntervalMS *int `json:"spinner_interval_ms,omitempty"`
99+ SyntaxBaseStyle *string `json:"syntax_base_style,omitempty"`
100+ Syntax SyntaxThemeOverrides `json:"syntax,omitempty"`
101+}
102+
103+type SyntaxThemeOverrides struct {
104+ Keyword *string `json:"keyword,omitempty"`
105+ KeywordConstant *string `json:"keyword_constant,omitempty"`
106+ KeywordDeclaration *string `json:"keyword_declaration,omitempty"`
107+ KeywordNamespace *string `json:"keyword_namespace,omitempty"`
108+ KeywordReserved *string `json:"keyword_reserved,omitempty"`
109+ KeywordType *string `json:"keyword_type,omitempty"`
110+ NameBuiltin *string `json:"name_builtin,omitempty"`
111+ NameFunction *string `json:"name_function,omitempty"`
112+ NameClass *string `json:"name_class,omitempty"`
113+ NameDecorator *string `json:"name_decorator,omitempty"`
114+ LiteralString *string `json:"literal_string,omitempty"`
115+ LiteralStringEscape *string `json:"literal_string_escape,omitempty"`
116+ LiteralNumber *string `json:"literal_number,omitempty"`
117+ Comment *string `json:"comment,omitempty"`
118+ CommentPreproc *string `json:"comment_preproc,omitempty"`
119+ Operator *string `json:"operator,omitempty"`
120+ Punctuation *string `json:"punctuation,omitempty"`
121+ Text *string `json:"text,omitempty"`
122+}
123+
124+// TerminalColorValue accepts any of these JSON forms:
125+//
126+// 24 // xterm-256 color index
127+// "#42454b" // RGB hex
128+// {"mode":"ansi","index":100}
129+// {"mode":"rgb","r":66,"g":69,"b":75}
130+// {"mode":"256","index":254}
131+type TerminalColorValue struct {
132+ TerminalColor
133+}
134+
135+func (c *TerminalColorValue) UnmarshalJSON(data []byte) error {
136+ var index int
137+ if err := json.Unmarshal(data, &index); err == nil {
138+ c.TerminalColor = Color256(index)
139+ return nil
140+ }
141+ var s string
142+ if err := json.Unmarshal(data, &s); err == nil {
143+ if rgb, ok := parseHexColor(s); ok {
144+ c.TerminalColor = rgb
145+ return nil
146+ }
147+ return fmt.Errorf("invalid terminal color %q", s)
148+ }
149+ var obj struct {
150+ Mode string `json:"mode"`
151+ Index int `json:"index"`
152+ R int `json:"r"`
153+ G int `json:"g"`
154+ B int `json:"b"`
155+ }
156+ if err := json.Unmarshal(data, &obj); err != nil {
157+ return err
158+ }
159+ switch strings.ToLower(obj.Mode) {
160+ case "", "256", "color256", "xterm256":
161+ c.TerminalColor = Color256(obj.Index)
162+ case "ansi":
163+ c.TerminalColor = ColorANSI(obj.Index)
164+ case "rgb", "truecolor":
165+ c.TerminalColor = ColorRGB(obj.R, obj.G, obj.B)
166+ default:
167+ return fmt.Errorf("unknown terminal color mode %q", obj.Mode)
168+ }
169+ return nil
170+}
171+
172+// LoadThemeFromHome applies a custom theme from $ZOT_HOME/themes/*.json
173+// to detected. Empty/auto/default keeps the built-in detected theme.
174+// If preferred is set, it may be a theme name, a basename without
175+// .json, or an absolute/relative path.
176+func DetectThemeWithCustom(zotHome, preferred string, timeout time.Duration) (Theme, string, error) {
177+ detected := DetectThemeFromBackground(timeout)
178+ return LoadThemeFromHome(zotHome, preferred, detected)
179+}
180+
181+func LoadThemeFromHome(zotHome, preferred string, detected Theme) (Theme, string, error) {
182+ path, err := resolveThemePath(zotHome, preferred)
183+ if err != nil || path == "" {
184+ return detected, "", err
185+ }
186+ switch path {
187+ case "builtin:dark":
188+ return Dark, "dark", nil
189+ case "builtin:light":
190+ return Light, "light", nil
191+ }
192+ b, err := os.ReadFile(path)
193+ if err != nil {
194+ return detected, "", err
195+ }
196+ var tf ThemeFile
197+ if err := json.Unmarshal(b, &tf); err != nil {
198+ return detected, "", fmt.Errorf("parse theme %s: %w", path, err)
199+ }
200+ if tf.Name == "" {
201+ tf.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
202+ }
203+ base := applyThemeOverrides(detected, tf.Overrides)
204+ base = applyThemeOverrides(base, tf.Colors.Base)
205+ if isLightTheme(detected) {
206+ if tf.Colors.HasLight {
207+ base = applyThemeOverrides(base, tf.Colors.Light)
208+ } else if tf.Colors.HasDark {
209+ base = applyThemeOverrides(base, tf.Colors.Dark)
210+ }
211+ } else {
212+ if tf.Colors.HasDark {
213+ base = applyThemeOverrides(base, tf.Colors.Dark)
214+ } else if tf.Colors.HasLight {
215+ base = applyThemeOverrides(base, tf.Colors.Light)
216+ }
217+ }
218+ return base, tf.Name, nil
219+}
220+
221+// AvailableThemes returns built-in and user-installed themes suitable
222+// for a settings picker. Invalid JSON files are skipped.
223+func AvailableThemes(zotHome string) []ThemeOption {
224+ out := []ThemeOption{
225+ {Value: "auto", Label: "auto", Description: "detect terminal background and use zot defaults", Builtin: true},
226+ {Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true},
227+ {Value: "light", Label: "light", Description: "built-in light theme", Builtin: true},
228+ }
229+ seen := map[string]bool{"auto": true, "dark": true, "light": true}
230+ paths, _ := themeFilesIn(filepath.Join(zotHome, "themes"))
231+ sort.Strings(paths)
232+ for _, path := range paths {
233+ b, err := os.ReadFile(path)
234+ if err != nil {
235+ continue
236+ }
237+ var tf ThemeFile
238+ if err := json.Unmarshal(b, &tf); err != nil {
239+ continue
240+ }
241+ value := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
242+ if value == "" || seen[value] {
243+ continue
244+ }
245+ desc := tf.Description
246+ if desc == "" {
247+ desc = path
248+ }
249+ out = append(out, ThemeOption{Value: value, Label: value, Description: desc, Path: path})
250+ seen[value] = true
251+ }
252+ return out
253+}
254+
255+// ThemeOptionFromFile parses one theme JSON file for picker display.
256+// value is what will be stored in config; pass an absolute path for
257+// extension-owned themes so they can be loaded without copying into
258+// $ZOT_HOME/themes.
259+func ThemeOptionFromFile(path, value, source string) (ThemeOption, bool) {
260+ b, err := os.ReadFile(path)
261+ if err != nil {
262+ return ThemeOption{}, false
263+ }
264+ var tf ThemeFile
265+ if err := json.Unmarshal(b, &tf); err != nil {
266+ return ThemeOption{}, false
267+ }
268+ if value == "" {
269+ value = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
270+ }
271+ label := value
272+ if tf.Name != "" {
273+ label = tf.Name
274+ }
275+ desc := tf.Description
276+ if source != "" {
277+ if desc != "" {
278+ desc = "from " + source + " — " + desc
279+ } else {
280+ desc = "from " + source
281+ }
282+ }
283+ if desc == "" {
284+ desc = path
285+ }
286+ return ThemeOption{Value: value, Label: label, Description: desc, Path: path}, true
287+}
288+
289+func ThemeExists(zotHome, preferred string) bool {
290+ path, err := resolveThemePath(zotHome, preferred)
291+ return err == nil && path != ""
292+}
293+
294+func resolveThemePath(zotHome, preferred string) (string, error) {
295+ preferred = strings.TrimSpace(preferred)
296+ switch strings.ToLower(preferred) {
297+ case "", "auto", "default", "system":
298+ return "", nil
299+ case "dark":
300+ return "builtin:dark", nil
301+ case "light":
302+ return "builtin:light", nil
303+ }
304+ if preferred != "" && !strings.HasPrefix(preferred, "builtin:") {
305+ candidates := []string{preferred}
306+ if filepath.Ext(preferred) == "" {
307+ candidates = append(candidates,
308+ filepath.Join(zotHome, "themes", preferred+".json"),
309+ )
310+ } else if !filepath.IsAbs(preferred) {
311+ candidates = append(candidates,
312+ filepath.Join(zotHome, "themes", preferred),
313+ )
314+ }
315+ for _, c := range candidates {
316+ if _, err := os.Stat(c); err == nil {
317+ return c, nil
318+ }
319+ }
320+ return "", fmt.Errorf("theme %q not found", preferred)
321+ }
322+
323+ return "", nil
324+}
325+
326+func themeFilesIn(dir string) ([]string, error) {
327+ entries, err := os.ReadDir(dir)
328+ if errors.Is(err, os.ErrNotExist) {
329+ return nil, nil
330+ }
331+ if err != nil {
332+ return nil, err
333+ }
334+ var out []string
335+ for _, e := range entries {
336+ if e.IsDir() || strings.ToLower(filepath.Ext(e.Name())) != ".json" {
337+ continue
338+ }
339+ out = append(out, filepath.Join(dir, e.Name()))
340+ }
341+ return out, nil
342+}
343+
344+func applyThemeOverrides(th Theme, o ThemeOverrides) Theme {
345+ if o.FG != nil {
346+ th.FG = *o.FG
347+ }
348+ if o.Muted != nil {
349+ th.Muted = *o.Muted
350+ }
351+ if o.Accent != nil {
352+ th.Accent = *o.Accent
353+ }
354+ if o.User != nil {
355+ th.User = *o.User
356+ }
357+ if o.UserBubbleBG != nil {
358+ th.UserBubbleBG = o.UserBubbleBG.TerminalColor
359+ }
360+ if o.UserBubbleFG != nil {
361+ th.UserBubbleFG = *o.UserBubbleFG
362+ }
363+ if o.Assistant != nil {
364+ th.Assistant = *o.Assistant
365+ }
366+ if o.Tool != nil {
367+ th.Tool = *o.Tool
368+ }
369+ if o.ToolOut != nil {
370+ th.ToolOut = *o.ToolOut
371+ }
372+ if o.Error != nil {
373+ th.Error = *o.Error
374+ }
375+ if o.Warning != nil {
376+ th.Warning = *o.Warning
377+ }
378+ if o.Spinner != nil {
379+ th.Spinner = *o.Spinner
380+ }
381+ if o.SelectionBG != nil {
382+ th.SelectionBG = *o.SelectionBG
383+ }
384+ if o.SelectionFG != nil {
385+ th.SelectionFG = *o.SelectionFG
386+ }
387+ if len(o.SpinnerFrames) > 0 {
388+ th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...)
389+ }
390+ if len(o.SpinnerMessages) > 0 {
391+ th.SpinnerMessages = append([]string(nil), o.SpinnerMessages...)
392+ }
393+ if o.SpinnerIntervalMS != nil && *o.SpinnerIntervalMS > 0 {
394+ th.SpinnerIntervalMS = *o.SpinnerIntervalMS
395+ }
396+ if o.SyntaxBaseStyle != nil {
397+ th.SyntaxBaseStyle = *o.SyntaxBaseStyle
398+ }
399+ th.Syntax = applySyntaxOverrides(th.Syntax, o.Syntax)
400+ return th
401+}
402+
403+func applySyntaxOverrides(s SyntaxTheme, o SyntaxThemeOverrides) SyntaxTheme {
404+ if o.Keyword != nil {
405+ s.Keyword = *o.Keyword
406+ }
407+ if o.KeywordConstant != nil {
408+ s.KeywordConstant = *o.KeywordConstant
409+ }
410+ if o.KeywordDeclaration != nil {
411+ s.KeywordDeclaration = *o.KeywordDeclaration
412+ }
413+ if o.KeywordNamespace != nil {
414+ s.KeywordNamespace = *o.KeywordNamespace
415+ }
416+ if o.KeywordReserved != nil {
417+ s.KeywordReserved = *o.KeywordReserved
418+ }
419+ if o.KeywordType != nil {
420+ s.KeywordType = *o.KeywordType
421+ }
422+ if o.NameBuiltin != nil {
423+ s.NameBuiltin = *o.NameBuiltin
424+ }
425+ if o.NameFunction != nil {
426+ s.NameFunction = *o.NameFunction
427+ }
428+ if o.NameClass != nil {
429+ s.NameClass = *o.NameClass
430+ }
431+ if o.NameDecorator != nil {
432+ s.NameDecorator = *o.NameDecorator
433+ }
434+ if o.LiteralString != nil {
435+ s.LiteralString = *o.LiteralString
436+ }
437+ if o.LiteralStringEscape != nil {
438+ s.LiteralStringEscape = *o.LiteralStringEscape
439+ }
440+ if o.LiteralNumber != nil {
441+ s.LiteralNumber = *o.LiteralNumber
442+ }
443+ if o.Comment != nil {
444+ s.Comment = *o.Comment
445+ }
446+ if o.CommentPreproc != nil {
447+ s.CommentPreproc = *o.CommentPreproc
448+ }
449+ if o.Operator != nil {
450+ s.Operator = *o.Operator
451+ }
452+ if o.Punctuation != nil {
453+ s.Punctuation = *o.Punctuation
454+ }
455+ if o.Text != nil {
456+ s.Text = *o.Text
457+ }
458+ return s
459+}
460+
461+func IsLightTheme(th Theme) bool {
462+ return th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG
463+}
464+
465+func isLightTheme(th Theme) bool { return IsLightTheme(th) }
466+
467+func parseHexColor(s string) (TerminalColor, bool) {
468+ s = strings.TrimPrefix(strings.TrimSpace(s), "#")
469+ if len(s) != 6 {
470+ return TerminalColor{}, false
471+ }
472+ var rgb [3]int
473+ for i := 0; i < 3; i++ {
474+ v, ok := parseHexByte(s[i*2 : i*2+2])
475+ if !ok {
476+ return TerminalColor{}, false
477+ }
478+ rgb[i] = v
479+ }
480+ return ColorRGB(rgb[0], rgb[1], rgb[2]), true
481+}
482+
483+func parseHexByte(s string) (int, bool) {
484+ var n int
485+ for _, r := range s {
486+ n *= 16
487+ switch {
488+ case r >= '0' && r <= '9':
489+ n += int(r - '0')
490+ case r >= 'a' && r <= 'f':
491+ n += int(r-'a') + 10
492+ case r >= 'A' && r <= 'F':
493+ n += int(r-'A') + 10
494+ default:
495+ return 0, false
496+ }
497+ }
498+ return n, true
499+}
@@ -0,0 +1,499 @@
1+package tui
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "sort"
10+ "strings"
11+ "time"
12+)
13+
14+// ThemeOption is one selectable theme discovered under $ZOT_HOME/themes.
15+// Value is stored in config.json.
16+type ThemeOption struct {
17+ Value string
18+ Label string
19+ Description string
20+ Path string
21+ Builtin bool
22+}
23+
24+// ThemeFile is the user-editable JSON shape loaded from
25+// $ZOT_HOME/themes/*.json. It carries metadata plus separate overrides
26+// for dark and light terminals.
27+type ThemeFile struct {
28+ Name string `json:"name"`
29+ Description string `json:"description"`
30+ Colors ThemeFileColorModes `json:"colors"`
31+ Overrides ThemeOverrides `json:"-"`
32+}
33+
34+func (tf *ThemeFile) UnmarshalJSON(data []byte) error {
35+ type alias ThemeFile
36+ var a alias
37+ if err := json.Unmarshal(data, &a); err != nil {
38+ return err
39+ }
40+ // Allow a tiny theme file with overrides at the top level, e.g.
41+ // {"spinner_frames":[".","o"],"spinner_messages":["working"]}.
42+ // Metadata fields are ignored by ThemeOverrides because they do not
43+ // have matching json tags.
44+ _ = json.Unmarshal(data, &a.Overrides)
45+ *tf = ThemeFile(a)
46+ return nil
47+}
48+
49+type ThemeFileColorModes struct {
50+ Base ThemeOverrides `json:"-"`
51+ Dark ThemeOverrides `json:"dark"`
52+ Light ThemeOverrides `json:"light"`
53+ HasDark bool `json:"-"`
54+ HasLight bool `json:"-"`
55+}
56+
57+func (m *ThemeFileColorModes) UnmarshalJSON(data []byte) error {
58+ var raw map[string]json.RawMessage
59+ if err := json.Unmarshal(data, &raw); err != nil {
60+ return err
61+ }
62+ if v, ok := raw["dark"]; ok {
63+ m.HasDark = true
64+ _ = json.Unmarshal(v, &m.Dark)
65+ }
66+ if v, ok := raw["light"]; ok {
67+ m.HasLight = true
68+ _ = json.Unmarshal(v, &m.Light)
69+ }
70+ // Also allow colors to directly contain overrides shared by both
71+ // modes, e.g. {"colors":{"accent":204}}.
72+ var base ThemeOverrides
73+ _ = json.Unmarshal(data, &base)
74+ m.Base = base
75+ return nil
76+}
77+
78+// ThemeOverrides is intentionally pointer-based so a theme file can
79+// override only the colors it cares about and inherit the built-in
80+// dark/light defaults for everything else.
81+type ThemeOverrides struct {
82+ FG *int `json:"fg,omitempty"`
83+ Muted *int `json:"muted,omitempty"`
84+ Accent *int `json:"accent,omitempty"`
85+ User *int `json:"user,omitempty"`
86+ UserBubbleBG *TerminalColorValue `json:"user_bubble_bg,omitempty"`
87+ UserBubbleFG *int `json:"user_bubble_fg,omitempty"`
88+ Assistant *int `json:"assistant,omitempty"`
89+ Tool *int `json:"tool,omitempty"`
90+ ToolOut *int `json:"tool_out,omitempty"`
91+ Error *int `json:"error,omitempty"`
92+ Warning *int `json:"warning,omitempty"`
93+ Spinner *int `json:"spinner,omitempty"`
94+ SelectionBG *int `json:"selection_bg,omitempty"`
95+ SelectionFG *int `json:"selection_fg,omitempty"`
96+ SpinnerFrames []string `json:"spinner_frames,omitempty"`
97+ SpinnerMessages []string `json:"spinner_messages,omitempty"`
98+ SpinnerIntervalMS *int `json:"spinner_interval_ms,omitempty"`
99+ SyntaxBaseStyle *string `json:"syntax_base_style,omitempty"`
100+ Syntax SyntaxThemeOverrides `json:"syntax,omitempty"`
101+}
102+
103+type SyntaxThemeOverrides struct {
104+ Keyword *string `json:"keyword,omitempty"`
105+ KeywordConstant *string `json:"keyword_constant,omitempty"`
106+ KeywordDeclaration *string `json:"keyword_declaration,omitempty"`
107+ KeywordNamespace *string `json:"keyword_namespace,omitempty"`
108+ KeywordReserved *string `json:"keyword_reserved,omitempty"`
109+ KeywordType *string `json:"keyword_type,omitempty"`
110+ NameBuiltin *string `json:"name_builtin,omitempty"`
111+ NameFunction *string `json:"name_function,omitempty"`
112+ NameClass *string `json:"name_class,omitempty"`
113+ NameDecorator *string `json:"name_decorator,omitempty"`
114+ LiteralString *string `json:"literal_string,omitempty"`
115+ LiteralStringEscape *string `json:"literal_string_escape,omitempty"`
116+ LiteralNumber *string `json:"literal_number,omitempty"`
117+ Comment *string `json:"comment,omitempty"`
118+ CommentPreproc *string `json:"comment_preproc,omitempty"`
119+ Operator *string `json:"operator,omitempty"`
120+ Punctuation *string `json:"punctuation,omitempty"`
121+ Text *string `json:"text,omitempty"`
122+}
123+
124+// TerminalColorValue accepts any of these JSON forms:
125+//
126+// 24 // xterm-256 color index
127+// "#42454b" // RGB hex
128+// {"mode":"ansi","index":100}
129+// {"mode":"rgb","r":66,"g":69,"b":75}
130+// {"mode":"256","index":254}
131+type TerminalColorValue struct {
132+ TerminalColor
133+}
134+
135+func (c *TerminalColorValue) UnmarshalJSON(data []byte) error {
136+ var index int
137+ if err := json.Unmarshal(data, &index); err == nil {
138+ c.TerminalColor = Color256(index)
139+ return nil
140+ }
141+ var s string
142+ if err := json.Unmarshal(data, &s); err == nil {
143+ if rgb, ok := parseHexColor(s); ok {
144+ c.TerminalColor = rgb
145+ return nil
146+ }
147+ return fmt.Errorf("invalid terminal color %q", s)
148+ }
149+ var obj struct {
150+ Mode string `json:"mode"`
151+ Index int `json:"index"`
152+ R int `json:"r"`
153+ G int `json:"g"`
154+ B int `json:"b"`
155+ }
156+ if err := json.Unmarshal(data, &obj); err != nil {
157+ return err
158+ }
159+ switch strings.ToLower(obj.Mode) {
160+ case "", "256", "color256", "xterm256":
161+ c.TerminalColor = Color256(obj.Index)
162+ case "ansi":
163+ c.TerminalColor = ColorANSI(obj.Index)
164+ case "rgb", "truecolor":
165+ c.TerminalColor = ColorRGB(obj.R, obj.G, obj.B)
166+ default:
167+ return fmt.Errorf("unknown terminal color mode %q", obj.Mode)
168+ }
169+ return nil
170+}
171+
172+// LoadThemeFromHome applies a custom theme from $ZOT_HOME/themes/*.json
173+// to detected. Empty/auto/default keeps the built-in detected theme.
174+// If preferred is set, it may be a theme name, a basename without
175+// .json, or an absolute/relative path.
176+func DetectThemeWithCustom(zotHome, preferred string, timeout time.Duration) (Theme, string, error) {
177+ detected := DetectThemeFromBackground(timeout)
178+ return LoadThemeFromHome(zotHome, preferred, detected)
179+}
180+
181+func LoadThemeFromHome(zotHome, preferred string, detected Theme) (Theme, string, error) {
182+ path, err := resolveThemePath(zotHome, preferred)
183+ if err != nil || path == "" {
184+ return detected, "", err
185+ }
186+ switch path {
187+ case "builtin:dark":
188+ return Dark, "dark", nil
189+ case "builtin:light":
190+ return Light, "light", nil
191+ }
192+ b, err := os.ReadFile(path)
193+ if err != nil {
194+ return detected, "", err
195+ }
196+ var tf ThemeFile
197+ if err := json.Unmarshal(b, &tf); err != nil {
198+ return detected, "", fmt.Errorf("parse theme %s: %w", path, err)
199+ }
200+ if tf.Name == "" {
201+ tf.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
202+ }
203+ base := applyThemeOverrides(detected, tf.Overrides)
204+ base = applyThemeOverrides(base, tf.Colors.Base)
205+ if isLightTheme(detected) {
206+ if tf.Colors.HasLight {
207+ base = applyThemeOverrides(base, tf.Colors.Light)
208+ } else if tf.Colors.HasDark {
209+ base = applyThemeOverrides(base, tf.Colors.Dark)
210+ }
211+ } else {
212+ if tf.Colors.HasDark {
213+ base = applyThemeOverrides(base, tf.Colors.Dark)
214+ } else if tf.Colors.HasLight {
215+ base = applyThemeOverrides(base, tf.Colors.Light)
216+ }
217+ }
218+ return base, tf.Name, nil
219+}
220+
221+// AvailableThemes returns built-in and user-installed themes suitable
222+// for a settings picker. Invalid JSON files are skipped.
223+func AvailableThemes(zotHome string) []ThemeOption {
224+ out := []ThemeOption{
225+ {Value: "auto", Label: "auto", Description: "detect terminal background and use zot defaults", Builtin: true},
226+ {Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true},
227+ {Value: "light", Label: "light", Description: "built-in light theme", Builtin: true},
228+ }
229+ seen := map[string]bool{"auto": true, "dark": true, "light": true}
230+ paths, _ := themeFilesIn(filepath.Join(zotHome, "themes"))
231+ sort.Strings(paths)
232+ for _, path := range paths {
233+ b, err := os.ReadFile(path)
234+ if err != nil {
235+ continue
236+ }
237+ var tf ThemeFile
238+ if err := json.Unmarshal(b, &tf); err != nil {
239+ continue
240+ }
241+ value := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
242+ if value == "" || seen[value] {
243+ continue
244+ }
245+ desc := tf.Description
246+ if desc == "" {
247+ desc = path
248+ }
249+ out = append(out, ThemeOption{Value: value, Label: value, Description: desc, Path: path})
250+ seen[value] = true
251+ }
252+ return out
253+}
254+
255+// ThemeOptionFromFile parses one theme JSON file for picker display.
256+// value is what will be stored in config; pass an absolute path for
257+// extension-owned themes so they can be loaded without copying into
258+// $ZOT_HOME/themes.
259+func ThemeOptionFromFile(path, value, source string) (ThemeOption, bool) {
260+ b, err := os.ReadFile(path)
261+ if err != nil {
262+ return ThemeOption{}, false
263+ }
264+ var tf ThemeFile
265+ if err := json.Unmarshal(b, &tf); err != nil {
266+ return ThemeOption{}, false
267+ }
268+ if value == "" {
269+ value = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
270+ }
271+ label := value
272+ if tf.Name != "" {
273+ label = tf.Name
274+ }
275+ desc := tf.Description
276+ if source != "" {
277+ if desc != "" {
278+ desc = "from " + source + " — " + desc
279+ } else {
280+ desc = "from " + source
281+ }
282+ }
283+ if desc == "" {
284+ desc = path
285+ }
286+ return ThemeOption{Value: value, Label: label, Description: desc, Path: path}, true
287+}
288+
289+func ThemeExists(zotHome, preferred string) bool {
290+ path, err := resolveThemePath(zotHome, preferred)
291+ return err == nil && path != ""
292+}
293+
294+func resolveThemePath(zotHome, preferred string) (string, error) {
295+ preferred = strings.TrimSpace(preferred)
296+ switch strings.ToLower(preferred) {
297+ case "", "auto", "default", "system":
298+ return "", nil
299+ case "dark":
300+ return "builtin:dark", nil
301+ case "light":
302+ return "builtin:light", nil
303+ }
304+ if preferred != "" && !strings.HasPrefix(preferred, "builtin:") {
305+ candidates := []string{preferred}
306+ if filepath.Ext(preferred) == "" {
307+ candidates = append(candidates,
308+ filepath.Join(zotHome, "themes", preferred+".json"),
309+ )
310+ } else if !filepath.IsAbs(preferred) {
311+ candidates = append(candidates,
312+ filepath.Join(zotHome, "themes", preferred),
313+ )
314+ }
315+ for _, c := range candidates {
316+ if _, err := os.Stat(c); err == nil {
317+ return c, nil
318+ }
319+ }
320+ return "", fmt.Errorf("theme %q not found", preferred)
321+ }
322+
323+ return "", nil
324+}
325+
326+func themeFilesIn(dir string) ([]string, error) {
327+ entries, err := os.ReadDir(dir)
328+ if errors.Is(err, os.ErrNotExist) {
329+ return nil, nil
330+ }
331+ if err != nil {
332+ return nil, err
333+ }
334+ var out []string
335+ for _, e := range entries {
336+ if e.IsDir() || strings.ToLower(filepath.Ext(e.Name())) != ".json" {
337+ continue
338+ }
339+ out = append(out, filepath.Join(dir, e.Name()))
340+ }
341+ return out, nil
342+}
343+
344+func applyThemeOverrides(th Theme, o ThemeOverrides) Theme {
345+ if o.FG != nil {
346+ th.FG = *o.FG
347+ }
348+ if o.Muted != nil {
349+ th.Muted = *o.Muted
350+ }
351+ if o.Accent != nil {
352+ th.Accent = *o.Accent
353+ }
354+ if o.User != nil {
355+ th.User = *o.User
356+ }
357+ if o.UserBubbleBG != nil {
358+ th.UserBubbleBG = o.UserBubbleBG.TerminalColor
359+ }
360+ if o.UserBubbleFG != nil {
361+ th.UserBubbleFG = *o.UserBubbleFG
362+ }
363+ if o.Assistant != nil {
364+ th.Assistant = *o.Assistant
365+ }
366+ if o.Tool != nil {
367+ th.Tool = *o.Tool
368+ }
369+ if o.ToolOut != nil {
370+ th.ToolOut = *o.ToolOut
371+ }
372+ if o.Error != nil {
373+ th.Error = *o.Error
374+ }
375+ if o.Warning != nil {
376+ th.Warning = *o.Warning
377+ }
378+ if o.Spinner != nil {
379+ th.Spinner = *o.Spinner
380+ }
381+ if o.SelectionBG != nil {
382+ th.SelectionBG = *o.SelectionBG
383+ }
384+ if o.SelectionFG != nil {
385+ th.SelectionFG = *o.SelectionFG
386+ }
387+ if len(o.SpinnerFrames) > 0 {
388+ th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...)
389+ }
390+ if len(o.SpinnerMessages) > 0 {
391+ th.SpinnerMessages = append([]string(nil), o.SpinnerMessages...)
392+ }
393+ if o.SpinnerIntervalMS != nil && *o.SpinnerIntervalMS > 0 {
394+ th.SpinnerIntervalMS = *o.SpinnerIntervalMS
395+ }
396+ if o.SyntaxBaseStyle != nil {
397+ th.SyntaxBaseStyle = *o.SyntaxBaseStyle
398+ }
399+ th.Syntax = applySyntaxOverrides(th.Syntax, o.Syntax)
400+ return th
401+}
402+
403+func applySyntaxOverrides(s SyntaxTheme, o SyntaxThemeOverrides) SyntaxTheme {
404+ if o.Keyword != nil {
405+ s.Keyword = *o.Keyword
406+ }
407+ if o.KeywordConstant != nil {
408+ s.KeywordConstant = *o.KeywordConstant
409+ }
410+ if o.KeywordDeclaration != nil {
411+ s.KeywordDeclaration = *o.KeywordDeclaration
412+ }
413+ if o.KeywordNamespace != nil {
414+ s.KeywordNamespace = *o.KeywordNamespace
415+ }
416+ if o.KeywordReserved != nil {
417+ s.KeywordReserved = *o.KeywordReserved
418+ }
419+ if o.KeywordType != nil {
420+ s.KeywordType = *o.KeywordType
421+ }
422+ if o.NameBuiltin != nil {
423+ s.NameBuiltin = *o.NameBuiltin
424+ }
425+ if o.NameFunction != nil {
426+ s.NameFunction = *o.NameFunction
427+ }
428+ if o.NameClass != nil {
429+ s.NameClass = *o.NameClass
430+ }
431+ if o.NameDecorator != nil {
432+ s.NameDecorator = *o.NameDecorator
433+ }
434+ if o.LiteralString != nil {
435+ s.LiteralString = *o.LiteralString
436+ }
437+ if o.LiteralStringEscape != nil {
438+ s.LiteralStringEscape = *o.LiteralStringEscape
439+ }
440+ if o.LiteralNumber != nil {
441+ s.LiteralNumber = *o.LiteralNumber
442+ }
443+ if o.Comment != nil {
444+ s.Comment = *o.Comment
445+ }
446+ if o.CommentPreproc != nil {
447+ s.CommentPreproc = *o.CommentPreproc
448+ }
449+ if o.Operator != nil {
450+ s.Operator = *o.Operator
451+ }
452+ if o.Punctuation != nil {
453+ s.Punctuation = *o.Punctuation
454+ }
455+ if o.Text != nil {
456+ s.Text = *o.Text
457+ }
458+ return s
459+}
460+
461+func IsLightTheme(th Theme) bool {
462+ return th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG
463+}
464+
465+func isLightTheme(th Theme) bool { return IsLightTheme(th) }
466+
467+func parseHexColor(s string) (TerminalColor, bool) {
468+ s = strings.TrimPrefix(strings.TrimSpace(s), "#")
469+ if len(s) != 6 {
470+ return TerminalColor{}, false
471+ }
472+ var rgb [3]int
473+ for i := 0; i < 3; i++ {
474+ v, ok := parseHexByte(s[i*2 : i*2+2])
475+ if !ok {
476+ return TerminalColor{}, false
477+ }
478+ rgb[i] = v
479+ }
480+ return ColorRGB(rgb[0], rgb[1], rgb[2]), true
481+}
482+
483+func parseHexByte(s string) (int, bool) {
484+ var n int
485+ for _, r := range s {
486+ n *= 16
487+ switch {
488+ case r >= '0' && r <= '9':
489+ n += int(r - '0')
490+ case r >= 'a' && r <= 'f':
491+ n += int(r-'a') + 10
492+ case r >= 'A' && r <= 'F':
493+ n += int(r-'A') + 10
494+ default:
495+ return 0, false
496+ }
497+ }
498+ return n, true
499+}
A packages/tui/theme_loader_test.go +113 -0
@@ -0,0 +1,113 @@
1+package tui
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
10+ home := t.TempDir()
11+ if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
12+ t.Fatal(err)
13+ }
14+ path := filepath.Join(home, "themes", "partial.json")
15+ if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"accent":204}}}`), 0o644); err != nil {
16+ t.Fatal(err)
17+ }
18+
19+ th, name, err := LoadThemeFromHome(home, "partial", Dark)
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ if name != "partial" {
24+ t.Fatalf("name = %q, want partial", name)
25+ }
26+ if th.Accent != 204 {
27+ t.Fatalf("accent = %d, want 204", th.Accent)
28+ }
29+ if th.FG != Dark.FG {
30+ t.Fatalf("fg = %d, want inherited %d", th.FG, Dark.FG)
31+ }
32+ if len(th.SpinnerFrames) == 0 || len(th.SpinnerMessages) == 0 {
33+ t.Fatal("spinner defaults should be inherited")
34+ }
35+}
36+
37+func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) {
38+ home := t.TempDir()
39+ if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
40+ t.Fatal(err)
41+ }
42+ path := filepath.Join(home, "themes", "spinner.json")
43+ if err := os.WriteFile(path, []byte(`{"spinner_frames":[".","o"],"spinner_messages":["working"],"spinner_interval_ms":200}`), 0o644); err != nil {
44+ t.Fatal(err)
45+ }
46+
47+ th, _, err := LoadThemeFromHome(home, "spinner", Dark)
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ if got := len(th.SpinnerFrames); got != 2 {
52+ t.Fatalf("spinner frame count = %d, want 2", got)
53+ }
54+ if th.SpinnerFrames[1] != "o" || th.SpinnerMessages[0] != "working" || th.SpinnerIntervalMS != 200 {
55+ t.Fatalf("spinner overrides not applied: %#v %#v %d", th.SpinnerFrames, th.SpinnerMessages, th.SpinnerIntervalMS)
56+ }
57+ if th.Accent != Dark.Accent {
58+ t.Fatalf("accent = %d, want inherited %d", th.Accent, Dark.Accent)
59+ }
60+}
61+
62+func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
63+ home := t.TempDir()
64+ if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
65+ t.Fatal(err)
66+ }
67+ path := filepath.Join(home, "themes", "darkonly.json")
68+ if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`), 0o644); err != nil {
69+ t.Fatal(err)
70+ }
71+
72+ th, _, err := LoadThemeFromHome(home, "darkonly", Light)
73+ if err != nil {
74+ t.Fatal(err)
75+ }
76+ if len(th.SpinnerFrames) != 4 || th.SpinnerFrames[0] != "◢" {
77+ t.Fatalf("spinner frames = %#v, want dark fallback frames", th.SpinnerFrames)
78+ }
79+ if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "working" {
80+ t.Fatalf("spinner messages = %#v, want dark fallback message", th.SpinnerMessages)
81+ }
82+ if th.SpinnerIntervalMS != 120 {
83+ t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS)
84+ }
85+ if th.FG != Light.FG {
86+ t.Fatalf("fg = %d, want inherited light fg %d", th.FG, Light.FG)
87+ }
88+}
89+
90+func TestLoadThemeAllowsSharedColorsOverrides(t *testing.T) {
91+ home := t.TempDir()
92+ if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
93+ t.Fatal(err)
94+ }
95+ path := filepath.Join(home, "themes", "shared.json")
96+ if err := os.WriteFile(path, []byte(`{"colors":{"accent":204,"spinner_messages":["ship"]}}`), 0o644); err != nil {
97+ t.Fatal(err)
98+ }
99+
100+ th, _, err := LoadThemeFromHome(home, "shared", Light)
101+ if err != nil {
102+ t.Fatal(err)
103+ }
104+ if th.Accent != 204 {
105+ t.Fatalf("accent = %d, want 204", th.Accent)
106+ }
107+ if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "ship" {
108+ t.Fatalf("spinner messages = %#v, want ship", th.SpinnerMessages)
109+ }
110+ if th.FG != Light.FG {
111+ t.Fatalf("fg = %d, want inherited %d", th.FG, Light.FG)
112+ }
113+}
@@ -0,0 +1,113 @@
1+package tui
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
10+ home := t.TempDir()
11+ if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
12+ t.Fatal(err)
13+ }
14+ path := filepath.Join(home, "themes", "partial.json")
15+ if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"accent":204}}}`), 0o644); err != nil {
16+ t.Fatal(err)
17+ }
18+
19+ th, name, err := LoadThemeFromHome(home, "partial", Dark)
20+ if err != nil {
21+ t.Fatal(err)
22+ }
23+ if name != "partial" {
24+ t.Fatalf("name = %q, want partial", name)
25+ }
26+ if th.Accent != 204 {
27+ t.Fatalf("accent = %d, want 204", th.Accent)
28+ }
29+ if th.FG != Dark.FG {
30+ t.Fatalf("fg = %d, want inherited %d", th.FG, Dark.FG)
31+ }
32+ if len(th.SpinnerFrames) == 0 || len(th.SpinnerMessages) == 0 {
33+ t.Fatal("spinner defaults should be inherited")
34+ }
35+}
36+
37+func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) {
38+ home := t.TempDir()
39+ if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
40+ t.Fatal(err)
41+ }
42+ path := filepath.Join(home, "themes", "spinner.json")
43+ if err := os.WriteFile(path, []byte(`{"spinner_frames":[".","o"],"spinner_messages":["working"],"spinner_interval_ms":200}`), 0o644); err != nil {
44+ t.Fatal(err)
45+ }
46+
47+ th, _, err := LoadThemeFromHome(home, "spinner", Dark)
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ if got := len(th.SpinnerFrames); got != 2 {
52+ t.Fatalf("spinner frame count = %d, want 2", got)
53+ }
54+ if th.SpinnerFrames[1] != "o" || th.SpinnerMessages[0] != "working" || th.SpinnerIntervalMS != 200 {
55+ t.Fatalf("spinner overrides not applied: %#v %#v %d", th.SpinnerFrames, th.SpinnerMessages, th.SpinnerIntervalMS)
56+ }
57+ if th.Accent != Dark.Accent {
58+ t.Fatalf("accent = %d, want inherited %d", th.Accent, Dark.Accent)
59+ }
60+}
61+
62+func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
63+ home := t.TempDir()
64+ if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
65+ t.Fatal(err)
66+ }
67+ path := filepath.Join(home, "themes", "darkonly.json")
68+ if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`), 0o644); err != nil {
69+ t.Fatal(err)
70+ }
71+
72+ th, _, err := LoadThemeFromHome(home, "darkonly", Light)
73+ if err != nil {
74+ t.Fatal(err)
75+ }
76+ if len(th.SpinnerFrames) != 4 || th.SpinnerFrames[0] != "◢" {
77+ t.Fatalf("spinner frames = %#v, want dark fallback frames", th.SpinnerFrames)
78+ }
79+ if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "working" {
80+ t.Fatalf("spinner messages = %#v, want dark fallback message", th.SpinnerMessages)
81+ }
82+ if th.SpinnerIntervalMS != 120 {
83+ t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS)
84+ }
85+ if th.FG != Light.FG {
86+ t.Fatalf("fg = %d, want inherited light fg %d", th.FG, Light.FG)
87+ }
88+}
89+
90+func TestLoadThemeAllowsSharedColorsOverrides(t *testing.T) {
91+ home := t.TempDir()
92+ if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
93+ t.Fatal(err)
94+ }
95+ path := filepath.Join(home, "themes", "shared.json")
96+ if err := os.WriteFile(path, []byte(`{"colors":{"accent":204,"spinner_messages":["ship"]}}`), 0o644); err != nil {
97+ t.Fatal(err)
98+ }
99+
100+ th, _, err := LoadThemeFromHome(home, "shared", Light)
101+ if err != nil {
102+ t.Fatal(err)
103+ }
104+ if th.Accent != 204 {
105+ t.Fatalf("accent = %d, want 204", th.Accent)
106+ }
107+ if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "ship" {
108+ t.Fatalf("spinner messages = %#v, want ship", th.SpinnerMessages)
109+ }
110+ if th.FG != Light.FG {
111+ t.Fatalf("fg = %d, want inherited %d", th.FG, Light.FG)
112+ }
113+}
M packages/tui/view.go +4 -4
@@ -1290,7 +1290,7 @@ func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byt
12901290 lang := LanguageFromPath(sourcePath)
12911291 var codeRendered string
12921292 if lang != "" {
1293- if h := HighlightCode(code, lang); len(h) == 1 {
1293+ if h := v.Theme.HighlightCode(code, lang); len(h) == 1 {
12941294 codeRendered = h[0]
12951295 }
12961296 }
@@ -1334,7 +1334,7 @@ func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byt
13341334 trunc = code[:maxCode-3] + "..."
13351335 }
13361336 if lang != "" {
1337- if h := HighlightCode(trunc, lang); len(h) == 1 {
1337+ if h := v.Theme.HighlightCode(trunc, lang); len(h) == 1 {
13381338 codeRendered = h[0]
13391339 } else if mark == ' ' {
13401340 codeRendered = v.Theme.FG256(v.Theme.Muted, trunc)
@@ -1470,7 +1470,7 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string {
14701470 lang := LanguageFromPath(sourcePath)
14711471 var highlighted []string
14721472 if lang != "" {
1473- highlighted = HighlightCode(strings.Join(codes, "\n"), lang)
1473+ highlighted = v.Theme.HighlightCode(strings.Join(codes, "\n"), lang)
14741474 // Chroma sometimes collapses the trailing empty line. Pad to
14751475 // align with the gutter slice so per-line zipping works.
14761476 for len(highlighted) < len(codes) {
@@ -1773,7 +1773,7 @@ func (v *View) renderRawFile(text, sourcePath string, startLine int) []string {
17731773 lang := LanguageFromPath(sourcePath)
17741774 var highlighted []string
17751775 if lang != "" {
1776- highlighted = HighlightCode(strings.Join(code, "\n"), lang)
1776+ highlighted = v.Theme.HighlightCode(strings.Join(code, "\n"), lang)
17771777 for len(highlighted) < len(code) {
17781778 highlighted = append(highlighted, "")
17791779 }
@@ -1290,7 +1290,7 @@ func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byt
1290 lang := LanguageFromPath(sourcePath)1290 lang := LanguageFromPath(sourcePath)
1291 var codeRendered string1291 var codeRendered string
1292 if lang != "" {1292 if lang != "" {
1293- if h := HighlightCode(code, lang); len(h) == 1 {1293+ if h := v.Theme.HighlightCode(code, lang); len(h) == 1 {
1294 codeRendered = h[0]1294 codeRendered = h[0]
1295 }1295 }
1296 }1296 }
@@ -1334,7 +1334,7 @@ func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byt
1334 trunc = code[:maxCode-3] + "..."1334 trunc = code[:maxCode-3] + "..."
1335 }1335 }
1336 if lang != "" {1336 if lang != "" {
1337- if h := HighlightCode(trunc, lang); len(h) == 1 {1337+ if h := v.Theme.HighlightCode(trunc, lang); len(h) == 1 {
1338 codeRendered = h[0]1338 codeRendered = h[0]
1339 } else if mark == ' ' {1339 } else if mark == ' ' {
1340 codeRendered = v.Theme.FG256(v.Theme.Muted, trunc)1340 codeRendered = v.Theme.FG256(v.Theme.Muted, trunc)
@@ -1470,7 +1470,7 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string {
1470 lang := LanguageFromPath(sourcePath)1470 lang := LanguageFromPath(sourcePath)
1471 var highlighted []string1471 var highlighted []string
1472 if lang != "" {1472 if lang != "" {
1473- highlighted = HighlightCode(strings.Join(codes, "\n"), lang)1473+ highlighted = v.Theme.HighlightCode(strings.Join(codes, "\n"), lang)
1474 // Chroma sometimes collapses the trailing empty line. Pad to1474 // Chroma sometimes collapses the trailing empty line. Pad to
1475 // align with the gutter slice so per-line zipping works.1475 // align with the gutter slice so per-line zipping works.
1476 for len(highlighted) < len(codes) {1476 for len(highlighted) < len(codes) {
@@ -1773,7 +1773,7 @@ func (v *View) renderRawFile(text, sourcePath string, startLine int) []string {
1773 lang := LanguageFromPath(sourcePath)1773 lang := LanguageFromPath(sourcePath)
1774 var highlighted []string1774 var highlighted []string
1775 if lang != "" {1775 if lang != "" {
1776- highlighted = HighlightCode(strings.Join(code, "\n"), lang)1776+ highlighted = v.Theme.HighlightCode(strings.Join(code, "\n"), lang)
1777 for len(highlighted) < len(code) {1777 for len(highlighted) < len(code) {
1778 highlighted = append(highlighted, "")1778 highlighted = append(highlighted, "")
1779 }1779 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close