synapse-cli-agent 0.1.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
synapse/ui/theme.py
ADDED
|
@@ -0,0 +1,1154 @@
|
|
|
1
|
+
"""UI theme registry: built-ins, layered themes.json, runtime switch.
|
|
2
|
+
|
|
3
|
+
Config surfaces:
|
|
4
|
+
|
|
5
|
+
- ``settings.json`` key ``theme`` (active name), loaded via ``Settings.theme``
|
|
6
|
+
- ``.coding-agent/themes.json`` (user → project layers)::
|
|
7
|
+
|
|
8
|
+
{
|
|
9
|
+
"themes": {
|
|
10
|
+
"my-dark": {
|
|
11
|
+
"extends": "cursor-dark",
|
|
12
|
+
"label": "My Dark",
|
|
13
|
+
"bg": "#0d1117",
|
|
14
|
+
"user": "#58a6ff",
|
|
15
|
+
"top_pad_x": 1,
|
|
16
|
+
"top_gap": 3
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
Topbar layout metrics (CSS / packing) and optional region bands:
|
|
22
|
+
|
|
23
|
+
- ``top`` — whole-row ``#topbar`` background via ``$theme-top``
|
|
24
|
+
- ``top_pad_x`` — horizontal CSS padding cells (default 1; ``$theme-top-pad-x``)
|
|
25
|
+
- ``top_gap`` — cells between left/center/right slots (default 3)
|
|
26
|
+
- ``top_left`` / ``top_center`` / ``top_right`` — optional per-region backgrounds
|
|
27
|
+
- omit or empty region colors → no bands (default for every built-in theme)
|
|
28
|
+
|
|
29
|
+
Built-in ``ansi`` inherits the terminal palette with transparent surfaces
|
|
30
|
+
(terminal wallpaper / acrylic). Aliases: inherit, terminal, auto.
|
|
31
|
+
|
|
32
|
+
Runtime:
|
|
33
|
+
|
|
34
|
+
- ``bootstrap_theme(name, workspace=...)`` at app start
|
|
35
|
+
- ``set_theme(name, persist=True)`` from ``/theme``
|
|
36
|
+
- ``get_theme()`` for Rich/Textual paint paths
|
|
37
|
+
- ``apply_textual_theme(app)`` switches Textual ``App.theme`` (needed for
|
|
38
|
+
transparent / ANSI surfaces; solid palettes use dark/light shells)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
from collections.abc import Callable
|
|
45
|
+
from dataclasses import dataclass, fields, replace
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Any
|
|
48
|
+
|
|
49
|
+
from synapse.settings.config_paths import (
|
|
50
|
+
SETTINGS_FILENAME,
|
|
51
|
+
existing_files,
|
|
52
|
+
layered_config_dirs,
|
|
53
|
+
load_json_object,
|
|
54
|
+
load_layered_json,
|
|
55
|
+
project_config_dir,
|
|
56
|
+
user_config_dir,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
THEMES_FILENAME = "themes.json"
|
|
60
|
+
DEFAULT_THEME_NAME = "cursor-dark"
|
|
61
|
+
|
|
62
|
+
# Palette keys that may appear in themes.json entries (besides name/label/extends).
|
|
63
|
+
_PALETTE_KEYS = frozenset(
|
|
64
|
+
{
|
|
65
|
+
"fg",
|
|
66
|
+
"dim",
|
|
67
|
+
"muted",
|
|
68
|
+
"green",
|
|
69
|
+
"orange",
|
|
70
|
+
"bar",
|
|
71
|
+
"bg",
|
|
72
|
+
"top",
|
|
73
|
+
"top_left",
|
|
74
|
+
"top_center",
|
|
75
|
+
"top_right",
|
|
76
|
+
"top_pad_x",
|
|
77
|
+
"top_gap",
|
|
78
|
+
"user",
|
|
79
|
+
"border",
|
|
80
|
+
"border_focus",
|
|
81
|
+
"error",
|
|
82
|
+
"code_theme",
|
|
83
|
+
"rich_user",
|
|
84
|
+
"rich_info_border",
|
|
85
|
+
"rich_ok_border",
|
|
86
|
+
"rich_error",
|
|
87
|
+
"rich_activity",
|
|
88
|
+
"ansi",
|
|
89
|
+
"css_fg",
|
|
90
|
+
"css_dim",
|
|
91
|
+
"css_muted",
|
|
92
|
+
"css_green",
|
|
93
|
+
"css_orange",
|
|
94
|
+
"css_bar",
|
|
95
|
+
"css_user",
|
|
96
|
+
"css_error",
|
|
97
|
+
"css_border",
|
|
98
|
+
"css_border_focus",
|
|
99
|
+
"prompt_border",
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Integer layout keys in themes.json (not colors).
|
|
104
|
+
_INT_PALETTE_KEYS = frozenset({"top_pad_x", "top_gap"})
|
|
105
|
+
|
|
106
|
+
# Textual 支持的 border 类型。
|
|
107
|
+
_VALID_PROMPT_BORDER_STYLES: frozenset[str] = frozenset({
|
|
108
|
+
"ascii",
|
|
109
|
+
"blank",
|
|
110
|
+
"block",
|
|
111
|
+
"dashed",
|
|
112
|
+
"double",
|
|
113
|
+
"heavy",
|
|
114
|
+
"hidden",
|
|
115
|
+
"hkey",
|
|
116
|
+
"inner",
|
|
117
|
+
"none",
|
|
118
|
+
"outer",
|
|
119
|
+
"panel",
|
|
120
|
+
"round",
|
|
121
|
+
"solid",
|
|
122
|
+
"tab",
|
|
123
|
+
"tall",
|
|
124
|
+
"thick",
|
|
125
|
+
"vkey",
|
|
126
|
+
"wide",
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class Theme:
|
|
132
|
+
"""One complete UI palette (TUI CSS + Rich text styles).
|
|
133
|
+
|
|
134
|
+
For terminal-inherit themes (``ansi``), Rich Text styles use names like
|
|
135
|
+
``default`` / ``bright_black``, while Textual CSS needs ``ansi_default`` /
|
|
136
|
+
``transparent``. Optional ``css_*`` fields override the CSS side only.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
name: str
|
|
140
|
+
label: str
|
|
141
|
+
fg: str
|
|
142
|
+
dim: str
|
|
143
|
+
muted: str
|
|
144
|
+
green: str
|
|
145
|
+
orange: str
|
|
146
|
+
bar: str
|
|
147
|
+
bg: str
|
|
148
|
+
top: str
|
|
149
|
+
user: str
|
|
150
|
+
border: str
|
|
151
|
+
border_focus: str
|
|
152
|
+
error: str = "#f28b82"
|
|
153
|
+
code_theme: str = "monokai"
|
|
154
|
+
rich_user: str = "bold cyan"
|
|
155
|
+
rich_info_border: str = "blue"
|
|
156
|
+
rich_ok_border: str = "green"
|
|
157
|
+
rich_error: str = "bold red"
|
|
158
|
+
rich_activity: str = "cyan"
|
|
159
|
+
# When True, surfaces stay transparent and Textual uses native ANSI colors.
|
|
160
|
+
ansi: bool = False
|
|
161
|
+
# Topbar left/center/right band backgrounds (empty = derived defaults).
|
|
162
|
+
# ``none`` / ``off`` / ``transparent`` suppresses that band.
|
|
163
|
+
top_left: str = ""
|
|
164
|
+
top_center: str = ""
|
|
165
|
+
top_right: str = ""
|
|
166
|
+
# Outer horizontal padding of #topbar (cells). CSS: $theme-top-pad-x.
|
|
167
|
+
top_pad_x: int = 1
|
|
168
|
+
# Gap cells between left/center/right slots (packing gap_after).
|
|
169
|
+
top_gap: int = 3
|
|
170
|
+
# CSS-only overrides (empty -> use the matching Rich field above).
|
|
171
|
+
css_fg: str = ""
|
|
172
|
+
css_dim: str = ""
|
|
173
|
+
css_muted: str = ""
|
|
174
|
+
css_green: str = ""
|
|
175
|
+
css_orange: str = ""
|
|
176
|
+
css_bar: str = ""
|
|
177
|
+
css_user: str = ""
|
|
178
|
+
css_error: str = ""
|
|
179
|
+
css_border: str = ""
|
|
180
|
+
css_border_focus: str = ""
|
|
181
|
+
# Border style for #prompt input: tall, heavy, dashed, double, round, solid.
|
|
182
|
+
prompt_border: str = "tall"
|
|
183
|
+
|
|
184
|
+
def css_variables(self) -> dict[str, str]:
|
|
185
|
+
"""Textual stylesheet variables (names without leading ``$``)."""
|
|
186
|
+
pad = max(0, int(self.top_pad_x or 0))
|
|
187
|
+
border_style = self.prompt_border
|
|
188
|
+
if border_style not in _VALID_PROMPT_BORDER_STYLES:
|
|
189
|
+
border_style = "tall"
|
|
190
|
+
return {
|
|
191
|
+
"theme-fg": self.css_fg or self.fg,
|
|
192
|
+
"theme-dim": self.css_dim or self.dim,
|
|
193
|
+
"theme-muted": self.css_muted or self.muted,
|
|
194
|
+
"theme-green": self.css_green or self.green,
|
|
195
|
+
"theme-orange": self.css_orange or self.orange,
|
|
196
|
+
"theme-bar": self.css_bar or self.bar,
|
|
197
|
+
"theme-bg": self.bg,
|
|
198
|
+
"theme-top": self.top,
|
|
199
|
+
"theme-user": self.css_user or self.user,
|
|
200
|
+
"theme-border": self.css_border or self.border,
|
|
201
|
+
"theme-border-focus": self.css_border_focus or self.border_focus,
|
|
202
|
+
"theme-error": self.css_error or self.error,
|
|
203
|
+
"theme-top-pad-x": str(pad),
|
|
204
|
+
"theme-prompt-border-style": border_style,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def is_terminal_inherit(self) -> bool:
|
|
209
|
+
"""True when chrome should inherit terminal bg (transparent / ANSI)."""
|
|
210
|
+
if self.ansi:
|
|
211
|
+
return True
|
|
212
|
+
bg = (self.bg or "").strip().casefold()
|
|
213
|
+
return bg in {"transparent", "ansi_default", "default"}
|
|
214
|
+
|
|
215
|
+
def topbar_region_bands(self) -> dict[str, tuple[str, str]]:
|
|
216
|
+
"""Resolved left/center/right ``(fg, bg)`` for optional region bands.
|
|
217
|
+
|
|
218
|
+
Empty ``bg`` means no band paint (widget CSS ``$theme-top`` shows).
|
|
219
|
+
Built-ins leave ``top_*`` empty so defaults paint no blocks; set
|
|
220
|
+
``top_left`` / ``top_center`` / ``top_right`` in themes.json to enable.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
def resolve(explicit: str) -> str:
|
|
224
|
+
key = (explicit or "").strip()
|
|
225
|
+
if not key:
|
|
226
|
+
return ""
|
|
227
|
+
low = key.casefold()
|
|
228
|
+
if low in {"none", "off", "false", "0", "transparent", "inherit", "default"}:
|
|
229
|
+
return ""
|
|
230
|
+
return key
|
|
231
|
+
|
|
232
|
+
left_bg = resolve(self.top_left)
|
|
233
|
+
center_bg = resolve(self.top_center)
|
|
234
|
+
right_bg = resolve(self.top_right)
|
|
235
|
+
left_fg = self.fg or ("default" if self.is_terminal_inherit else "")
|
|
236
|
+
center_fg = self.fg or ("default" if self.is_terminal_inherit else "")
|
|
237
|
+
right_fg = self.dim or ("bright_black" if self.is_terminal_inherit else "")
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
"left": (left_fg, left_bg),
|
|
241
|
+
"center": (center_fg, center_bg),
|
|
242
|
+
"right": (right_fg, right_bg),
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _t(
|
|
247
|
+
name: str,
|
|
248
|
+
label: str,
|
|
249
|
+
*,
|
|
250
|
+
fg: str,
|
|
251
|
+
dim: str,
|
|
252
|
+
muted: str,
|
|
253
|
+
green: str,
|
|
254
|
+
orange: str,
|
|
255
|
+
bar: str,
|
|
256
|
+
bg: str,
|
|
257
|
+
top: str,
|
|
258
|
+
user: str,
|
|
259
|
+
border: str,
|
|
260
|
+
border_focus: str,
|
|
261
|
+
error: str = "#f28b82",
|
|
262
|
+
code_theme: str = "monokai",
|
|
263
|
+
rich_user: str = "bold cyan",
|
|
264
|
+
rich_info_border: str = "blue",
|
|
265
|
+
rich_ok_border: str = "green",
|
|
266
|
+
rich_error: str = "bold red",
|
|
267
|
+
rich_activity: str = "cyan",
|
|
268
|
+
ansi: bool = False,
|
|
269
|
+
top_left: str = "",
|
|
270
|
+
top_center: str = "",
|
|
271
|
+
top_right: str = "",
|
|
272
|
+
top_pad_x: int = 1,
|
|
273
|
+
top_gap: int = 3,
|
|
274
|
+
css_fg: str = "",
|
|
275
|
+
css_dim: str = "",
|
|
276
|
+
css_muted: str = "",
|
|
277
|
+
css_green: str = "",
|
|
278
|
+
css_orange: str = "",
|
|
279
|
+
css_bar: str = "",
|
|
280
|
+
css_user: str = "",
|
|
281
|
+
css_error: str = "",
|
|
282
|
+
css_border: str = "",
|
|
283
|
+
css_border_focus: str = "",
|
|
284
|
+
prompt_border: str = "tall",
|
|
285
|
+
) -> Theme:
|
|
286
|
+
return Theme(
|
|
287
|
+
name=name,
|
|
288
|
+
label=label,
|
|
289
|
+
fg=fg,
|
|
290
|
+
dim=dim,
|
|
291
|
+
muted=muted,
|
|
292
|
+
green=green,
|
|
293
|
+
orange=orange,
|
|
294
|
+
bar=bar,
|
|
295
|
+
bg=bg,
|
|
296
|
+
top=top,
|
|
297
|
+
user=user,
|
|
298
|
+
border=border,
|
|
299
|
+
border_focus=border_focus,
|
|
300
|
+
error=error,
|
|
301
|
+
code_theme=code_theme,
|
|
302
|
+
rich_user=rich_user,
|
|
303
|
+
rich_info_border=rich_info_border,
|
|
304
|
+
rich_ok_border=rich_ok_border,
|
|
305
|
+
rich_error=rich_error,
|
|
306
|
+
rich_activity=rich_activity,
|
|
307
|
+
ansi=ansi,
|
|
308
|
+
top_left=top_left,
|
|
309
|
+
top_center=top_center,
|
|
310
|
+
top_right=top_right,
|
|
311
|
+
top_pad_x=max(0, int(top_pad_x or 0)),
|
|
312
|
+
top_gap=max(0, int(top_gap or 0)),
|
|
313
|
+
css_fg=css_fg,
|
|
314
|
+
css_dim=css_dim,
|
|
315
|
+
css_muted=css_muted,
|
|
316
|
+
css_green=css_green,
|
|
317
|
+
css_orange=css_orange,
|
|
318
|
+
css_bar=css_bar,
|
|
319
|
+
css_user=css_user,
|
|
320
|
+
css_error=css_error,
|
|
321
|
+
css_border=css_border,
|
|
322
|
+
css_border_focus=css_border_focus,
|
|
323
|
+
prompt_border=prompt_border,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# Built-in classic palettes (dark + light + terminal-inherit ansi).
|
|
328
|
+
BUILTIN_THEMES: dict[str, Theme] = {
|
|
329
|
+
# Inherit terminal colors; transparent surfaces (acrylic / wallpaper).
|
|
330
|
+
# Rich Text: default / bright_black / green (no ansi_ prefix).
|
|
331
|
+
# CSS: transparent + ansi_* tokens for Textual native ANSI path.
|
|
332
|
+
"ansi": _t(
|
|
333
|
+
"ansi",
|
|
334
|
+
"Terminal (transparent)",
|
|
335
|
+
fg="default",
|
|
336
|
+
dim="bright_black",
|
|
337
|
+
muted="bright_black",
|
|
338
|
+
green="green",
|
|
339
|
+
orange="yellow",
|
|
340
|
+
bar="default",
|
|
341
|
+
bg="transparent",
|
|
342
|
+
top="transparent",
|
|
343
|
+
user="cyan",
|
|
344
|
+
border="bright_black",
|
|
345
|
+
border_focus="cyan",
|
|
346
|
+
error="red",
|
|
347
|
+
code_theme="ansi_dark",
|
|
348
|
+
rich_user="bold cyan",
|
|
349
|
+
rich_info_border="cyan",
|
|
350
|
+
rich_ok_border="green",
|
|
351
|
+
rich_error="bold red",
|
|
352
|
+
rich_activity="cyan",
|
|
353
|
+
ansi=True,
|
|
354
|
+
css_fg="ansi_default",
|
|
355
|
+
css_dim="ansi_bright_black",
|
|
356
|
+
css_muted="ansi_bright_black",
|
|
357
|
+
css_green="ansi_green",
|
|
358
|
+
css_orange="ansi_yellow",
|
|
359
|
+
css_bar="transparent",
|
|
360
|
+
css_user="ansi_cyan",
|
|
361
|
+
css_error="ansi_red",
|
|
362
|
+
css_border="ansi_bright_black",
|
|
363
|
+
css_border_focus="ansi_cyan",
|
|
364
|
+
),
|
|
365
|
+
"cursor-dark": _t(
|
|
366
|
+
"cursor-dark",
|
|
367
|
+
"Cursor Dark",
|
|
368
|
+
fg="#e8eaed",
|
|
369
|
+
dim="#9aa0a6",
|
|
370
|
+
muted="#5f6368",
|
|
371
|
+
green="#81c995",
|
|
372
|
+
orange="#f4b183",
|
|
373
|
+
bar="#2b2d31",
|
|
374
|
+
bg="#1a1b1e",
|
|
375
|
+
top="#121316",
|
|
376
|
+
user="#8ab4f8",
|
|
377
|
+
border="#3c4043",
|
|
378
|
+
border_focus="#5f6368",
|
|
379
|
+
code_theme="monokai",
|
|
380
|
+
),
|
|
381
|
+
"github-dark": _t(
|
|
382
|
+
"github-dark",
|
|
383
|
+
"GitHub Dark",
|
|
384
|
+
fg="#e6edf3",
|
|
385
|
+
dim="#8b949e",
|
|
386
|
+
muted="#6e7681",
|
|
387
|
+
green="#3fb950",
|
|
388
|
+
orange="#d29922",
|
|
389
|
+
bar="#21262d",
|
|
390
|
+
bg="#0d1117",
|
|
391
|
+
top="#010409",
|
|
392
|
+
user="#58a6ff",
|
|
393
|
+
border="#30363d",
|
|
394
|
+
border_focus="#8b949e",
|
|
395
|
+
error="#f85149",
|
|
396
|
+
code_theme="github-dark",
|
|
397
|
+
rich_user="bold #58a6ff",
|
|
398
|
+
rich_info_border="#58a6ff",
|
|
399
|
+
rich_ok_border="#3fb950",
|
|
400
|
+
rich_activity="#58a6ff",
|
|
401
|
+
),
|
|
402
|
+
"dracula": _t(
|
|
403
|
+
"dracula",
|
|
404
|
+
"Dracula",
|
|
405
|
+
fg="#f8f8f2",
|
|
406
|
+
dim="#bd93f9",
|
|
407
|
+
muted="#6272a4",
|
|
408
|
+
green="#50fa7b",
|
|
409
|
+
orange="#ffb86c",
|
|
410
|
+
bar="#44475a",
|
|
411
|
+
bg="#282a36",
|
|
412
|
+
top="#21222c",
|
|
413
|
+
user="#8be9fd",
|
|
414
|
+
border="#6272a4",
|
|
415
|
+
border_focus="#bd93f9",
|
|
416
|
+
error="#ff5555",
|
|
417
|
+
code_theme="dracula",
|
|
418
|
+
rich_user="bold #8be9fd",
|
|
419
|
+
rich_info_border="#bd93f9",
|
|
420
|
+
rich_ok_border="#50fa7b",
|
|
421
|
+
rich_activity="#ff79c6",
|
|
422
|
+
),
|
|
423
|
+
"nord": _t(
|
|
424
|
+
"nord",
|
|
425
|
+
"Nord",
|
|
426
|
+
fg="#eceff4",
|
|
427
|
+
dim="#d8dee9",
|
|
428
|
+
muted="#4c566a",
|
|
429
|
+
green="#a3be8c",
|
|
430
|
+
orange="#d08770",
|
|
431
|
+
bar="#3b4252",
|
|
432
|
+
bg="#2e3440",
|
|
433
|
+
top="#242933",
|
|
434
|
+
user="#88c0d0",
|
|
435
|
+
border="#4c566a",
|
|
436
|
+
border_focus="#81a1c1",
|
|
437
|
+
error="#bf616a",
|
|
438
|
+
code_theme="nord",
|
|
439
|
+
rich_user="bold #88c0d0",
|
|
440
|
+
rich_info_border="#81a1c1",
|
|
441
|
+
rich_ok_border="#a3be8c",
|
|
442
|
+
rich_activity="#88c0d0",
|
|
443
|
+
),
|
|
444
|
+
"solarized-dark": _t(
|
|
445
|
+
"solarized-dark",
|
|
446
|
+
"Solarized Dark",
|
|
447
|
+
fg="#93a1a1",
|
|
448
|
+
dim="#839496",
|
|
449
|
+
muted="#586e75",
|
|
450
|
+
green="#859900",
|
|
451
|
+
orange="#cb4b16",
|
|
452
|
+
bar="#073642",
|
|
453
|
+
bg="#002b36",
|
|
454
|
+
top="#001f27",
|
|
455
|
+
user="#268bd2",
|
|
456
|
+
border="#586e75",
|
|
457
|
+
border_focus="#839496",
|
|
458
|
+
error="#dc322f",
|
|
459
|
+
code_theme="solarized-dark",
|
|
460
|
+
rich_user="bold #268bd2",
|
|
461
|
+
rich_info_border="#268bd2",
|
|
462
|
+
rich_ok_border="#859900",
|
|
463
|
+
rich_activity="#2aa198",
|
|
464
|
+
),
|
|
465
|
+
"solarized-light": _t(
|
|
466
|
+
"solarized-light",
|
|
467
|
+
"Solarized Light",
|
|
468
|
+
fg="#465c63",
|
|
469
|
+
dim="#526b73",
|
|
470
|
+
muted="#657b83",
|
|
471
|
+
green="#859900",
|
|
472
|
+
orange="#cb4b16",
|
|
473
|
+
bar="#eee8d5",
|
|
474
|
+
bg="#fdf6e3",
|
|
475
|
+
top="#eee8d5",
|
|
476
|
+
user="#268bd2",
|
|
477
|
+
border="#93a1a1",
|
|
478
|
+
border_focus="#657b83",
|
|
479
|
+
error="#dc322f",
|
|
480
|
+
code_theme="solarized-light",
|
|
481
|
+
rich_user="bold #268bd2",
|
|
482
|
+
rich_info_border="#268bd2",
|
|
483
|
+
rich_ok_border="#859900",
|
|
484
|
+
rich_activity="#2aa198",
|
|
485
|
+
),
|
|
486
|
+
"catppuccin-mocha": _t(
|
|
487
|
+
"catppuccin-mocha",
|
|
488
|
+
"Catppuccin Mocha",
|
|
489
|
+
fg="#cdd6f4",
|
|
490
|
+
dim="#a6adc8",
|
|
491
|
+
muted="#6c7086",
|
|
492
|
+
green="#a6e3a1",
|
|
493
|
+
orange="#fab387",
|
|
494
|
+
bar="#313244",
|
|
495
|
+
bg="#1e1e2e",
|
|
496
|
+
top="#181825",
|
|
497
|
+
user="#89b4fa",
|
|
498
|
+
border="#45475a",
|
|
499
|
+
border_focus="#89b4fa",
|
|
500
|
+
error="#f38ba8",
|
|
501
|
+
code_theme="monokai",
|
|
502
|
+
rich_user="bold #89b4fa",
|
|
503
|
+
rich_info_border="#89b4fa",
|
|
504
|
+
rich_ok_border="#a6e3a1",
|
|
505
|
+
rich_activity="#cba6f7",
|
|
506
|
+
),
|
|
507
|
+
"one-dark": _t(
|
|
508
|
+
"one-dark",
|
|
509
|
+
"One Dark",
|
|
510
|
+
fg="#abb2bf",
|
|
511
|
+
dim="#828997",
|
|
512
|
+
muted="#5c6370",
|
|
513
|
+
green="#98c379",
|
|
514
|
+
orange="#d19a66",
|
|
515
|
+
bar="#2c313c",
|
|
516
|
+
bg="#282c34",
|
|
517
|
+
top="#21252b",
|
|
518
|
+
user="#61afef",
|
|
519
|
+
border="#3e4451",
|
|
520
|
+
border_focus="#61afef",
|
|
521
|
+
error="#e06c75",
|
|
522
|
+
code_theme="one-dark",
|
|
523
|
+
rich_user="bold #61afef",
|
|
524
|
+
rich_info_border="#61afef",
|
|
525
|
+
rich_ok_border="#98c379",
|
|
526
|
+
rich_activity="#c678dd",
|
|
527
|
+
),
|
|
528
|
+
"github-light": _t(
|
|
529
|
+
"github-light",
|
|
530
|
+
"GitHub Light",
|
|
531
|
+
fg="#1f2328",
|
|
532
|
+
dim="#656d76",
|
|
533
|
+
muted="#6b7280",
|
|
534
|
+
green="#1a7f37",
|
|
535
|
+
orange="#9a6700",
|
|
536
|
+
bar="#d8dee4",
|
|
537
|
+
bg="#f6f8fa",
|
|
538
|
+
top="#eaeef2",
|
|
539
|
+
user="#0969da",
|
|
540
|
+
border="#b8c2cc",
|
|
541
|
+
border_focus="#0969da",
|
|
542
|
+
error="#cf222e",
|
|
543
|
+
code_theme="default",
|
|
544
|
+
rich_user="bold #0969da",
|
|
545
|
+
rich_info_border="#0969da",
|
|
546
|
+
rich_ok_border="#1a7f37",
|
|
547
|
+
rich_activity="#8250df",
|
|
548
|
+
),
|
|
549
|
+
"one-light": _t(
|
|
550
|
+
"one-light",
|
|
551
|
+
"One Light",
|
|
552
|
+
fg="#383a42",
|
|
553
|
+
dim="#5d616c",
|
|
554
|
+
muted="#737680",
|
|
555
|
+
green="#50a14f",
|
|
556
|
+
orange="#c18401",
|
|
557
|
+
bar="#dedfe2",
|
|
558
|
+
bg="#f5f5f6",
|
|
559
|
+
top="#e8e8ea",
|
|
560
|
+
user="#4078f2",
|
|
561
|
+
border="#b9bbc1",
|
|
562
|
+
border_focus="#4078f2",
|
|
563
|
+
error="#e45649",
|
|
564
|
+
code_theme="default",
|
|
565
|
+
rich_user="bold #4078f2",
|
|
566
|
+
rich_info_border="#4078f2",
|
|
567
|
+
rich_ok_border="#50a14f",
|
|
568
|
+
rich_activity="#a626a4",
|
|
569
|
+
),
|
|
570
|
+
"gruvbox-light": _t(
|
|
571
|
+
"gruvbox-light",
|
|
572
|
+
"Gruvbox Light",
|
|
573
|
+
fg="#3c3836",
|
|
574
|
+
dim="#6f6258",
|
|
575
|
+
muted="#7c6f64",
|
|
576
|
+
green="#79740e",
|
|
577
|
+
orange="#af3a03",
|
|
578
|
+
bar="#ebdbb2",
|
|
579
|
+
bg="#fbf1c7",
|
|
580
|
+
top="#f2e5bc",
|
|
581
|
+
user="#076678",
|
|
582
|
+
border="#d5c4a1",
|
|
583
|
+
border_focus="#076678",
|
|
584
|
+
error="#cc241d",
|
|
585
|
+
code_theme="default",
|
|
586
|
+
rich_user="bold #076678",
|
|
587
|
+
rich_info_border="#076678",
|
|
588
|
+
rich_ok_border="#79740e",
|
|
589
|
+
rich_activity="#8f3f71",
|
|
590
|
+
),
|
|
591
|
+
"catppuccin-latte": _t(
|
|
592
|
+
"catppuccin-latte",
|
|
593
|
+
"Catppuccin Latte",
|
|
594
|
+
fg="#4c4f69",
|
|
595
|
+
dim="#5e6178",
|
|
596
|
+
muted="#73778a",
|
|
597
|
+
green="#40a02b",
|
|
598
|
+
orange="#fe640b",
|
|
599
|
+
bar="#e6e9ef",
|
|
600
|
+
bg="#eff1f5",
|
|
601
|
+
top="#dce0e8",
|
|
602
|
+
user="#1e66f5",
|
|
603
|
+
border="#ccd0da",
|
|
604
|
+
border_focus="#1e66f5",
|
|
605
|
+
error="#d20f39",
|
|
606
|
+
code_theme="default",
|
|
607
|
+
rich_user="bold #1e66f5",
|
|
608
|
+
rich_info_border="#1e66f5",
|
|
609
|
+
rich_ok_border="#40a02b",
|
|
610
|
+
rich_activity="#8839ef",
|
|
611
|
+
),
|
|
612
|
+
"tokyo-night-light": _t(
|
|
613
|
+
"tokyo-night-light",
|
|
614
|
+
"Tokyo Night Light",
|
|
615
|
+
fg="#343b58",
|
|
616
|
+
dim="#4c5168",
|
|
617
|
+
muted="#666d92",
|
|
618
|
+
green="#485e30",
|
|
619
|
+
orange="#965027",
|
|
620
|
+
bar="#c8d3f5",
|
|
621
|
+
bg="#d5d6db",
|
|
622
|
+
top="#c0c2ce",
|
|
623
|
+
user="#2e7de9",
|
|
624
|
+
border="#a8aecb",
|
|
625
|
+
border_focus="#2e7de9",
|
|
626
|
+
error="#8c4351",
|
|
627
|
+
code_theme="default",
|
|
628
|
+
rich_user="bold #2e7de9",
|
|
629
|
+
rich_info_border="#2e7de9",
|
|
630
|
+
rich_ok_border="#485e30",
|
|
631
|
+
rich_activity="#9854f1",
|
|
632
|
+
),
|
|
633
|
+
"ayu-light": _t(
|
|
634
|
+
"ayu-light",
|
|
635
|
+
"Ayu Light",
|
|
636
|
+
fg="#575f66",
|
|
637
|
+
dim="#5d6875",
|
|
638
|
+
muted="#687582",
|
|
639
|
+
green="#6cbf43",
|
|
640
|
+
orange="#f29718",
|
|
641
|
+
bar="#e3e7eb",
|
|
642
|
+
bg="#f6f8fa",
|
|
643
|
+
top="#e7ebef",
|
|
644
|
+
user="#399ee6",
|
|
645
|
+
border="#c5cbd2",
|
|
646
|
+
border_focus="#399ee6",
|
|
647
|
+
error="#f07178",
|
|
648
|
+
code_theme="default",
|
|
649
|
+
rich_user="bold #399ee6",
|
|
650
|
+
rich_info_border="#399ee6",
|
|
651
|
+
rich_ok_border="#6cbf43",
|
|
652
|
+
rich_activity="#a37acc",
|
|
653
|
+
),
|
|
654
|
+
"nord-light": _t(
|
|
655
|
+
"nord-light",
|
|
656
|
+
"Nord Light",
|
|
657
|
+
fg="#3b4252",
|
|
658
|
+
dim="#4c566a",
|
|
659
|
+
muted="#60738d",
|
|
660
|
+
green="#719839",
|
|
661
|
+
orange="#c98245",
|
|
662
|
+
bar="#e5e9f0",
|
|
663
|
+
bg="#eceff4",
|
|
664
|
+
top="#d8dee9",
|
|
665
|
+
user="#5e81ac",
|
|
666
|
+
border="#d8dee9",
|
|
667
|
+
border_focus="#5e81ac",
|
|
668
|
+
error="#bf616a",
|
|
669
|
+
code_theme="default",
|
|
670
|
+
rich_user="bold #5e81ac",
|
|
671
|
+
rich_info_border="#5e81ac",
|
|
672
|
+
rich_ok_border="#719839",
|
|
673
|
+
rich_activity="#b48ead",
|
|
674
|
+
),
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
_active: Theme = BUILTIN_THEMES[DEFAULT_THEME_NAME]
|
|
679
|
+
_custom: dict[str, Theme] = {}
|
|
680
|
+
_listeners: list[Callable[[Theme], None]] = []
|
|
681
|
+
_loaded_workspace: str | None = None
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def on_theme_change(callback: Callable[[Theme], None]) -> None:
|
|
685
|
+
"""Register a listener invoked after the active theme changes."""
|
|
686
|
+
if callback not in _listeners:
|
|
687
|
+
_listeners.append(callback)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def get_theme() -> Theme:
|
|
691
|
+
return _active
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def builtin_theme_names() -> list[str]:
|
|
695
|
+
return list(BUILTIN_THEMES.keys())
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def list_theme_names() -> list[str]:
|
|
699
|
+
"""Built-ins first (stable order), then custom names sorted."""
|
|
700
|
+
names = list(BUILTIN_THEMES.keys())
|
|
701
|
+
extras = sorted(n for n in _custom if n not in BUILTIN_THEMES)
|
|
702
|
+
return names + extras
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def list_themes() -> list[Theme]:
|
|
706
|
+
return [get_theme_by_name(n) for n in list_theme_names()]
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def get_theme_by_name(name: str) -> Theme:
|
|
710
|
+
key = (name or "").strip()
|
|
711
|
+
if key in _custom:
|
|
712
|
+
return _custom[key]
|
|
713
|
+
if key in BUILTIN_THEMES:
|
|
714
|
+
return BUILTIN_THEMES[key]
|
|
715
|
+
raise KeyError(f"unknown theme: {name!r}")
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _normalize_color(value: object) -> str | None:
|
|
719
|
+
if value is None:
|
|
720
|
+
return None
|
|
721
|
+
text = str(value).strip()
|
|
722
|
+
if not text:
|
|
723
|
+
return None
|
|
724
|
+
return text
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _theme_from_dict(
|
|
728
|
+
name: str,
|
|
729
|
+
data: dict[str, Any],
|
|
730
|
+
*,
|
|
731
|
+
catalog: dict[str, Theme],
|
|
732
|
+
stack: list[str] | None = None,
|
|
733
|
+
) -> Theme:
|
|
734
|
+
"""Build a Theme from a config dict, supporting ``extends``."""
|
|
735
|
+
stack = list(stack or [])
|
|
736
|
+
if name in stack:
|
|
737
|
+
raise ValueError(f"theme extends cycle: {' -> '.join([*stack, name])}")
|
|
738
|
+
stack.append(name)
|
|
739
|
+
|
|
740
|
+
extends = str(data.get("extends") or data.get("base") or "").strip()
|
|
741
|
+
if extends:
|
|
742
|
+
if extends in catalog:
|
|
743
|
+
base = catalog[extends]
|
|
744
|
+
elif extends in BUILTIN_THEMES:
|
|
745
|
+
base = BUILTIN_THEMES[extends]
|
|
746
|
+
elif extends in _custom:
|
|
747
|
+
base = _custom[extends]
|
|
748
|
+
else:
|
|
749
|
+
# Resolve peer custom not yet fully built.
|
|
750
|
+
raise KeyError(f"theme {name!r} extends unknown base {extends!r}")
|
|
751
|
+
else:
|
|
752
|
+
base = BUILTIN_THEMES[DEFAULT_THEME_NAME]
|
|
753
|
+
|
|
754
|
+
label = str(data.get("label") or data.get("title") or name).strip() or name
|
|
755
|
+
updates: dict[str, Any] = {"name": name, "label": label}
|
|
756
|
+
for key in _PALETTE_KEYS:
|
|
757
|
+
if key not in data:
|
|
758
|
+
continue
|
|
759
|
+
raw = data[key]
|
|
760
|
+
if key == "code_theme":
|
|
761
|
+
val = str(raw).strip() if raw is not None else ""
|
|
762
|
+
if val:
|
|
763
|
+
updates[key] = val
|
|
764
|
+
continue
|
|
765
|
+
if key == "prompt_border":
|
|
766
|
+
val = str(raw).strip() if raw is not None else ""
|
|
767
|
+
if val and val in _VALID_PROMPT_BORDER_STYLES:
|
|
768
|
+
updates[key] = val
|
|
769
|
+
continue
|
|
770
|
+
if key == "ansi":
|
|
771
|
+
if isinstance(raw, bool):
|
|
772
|
+
updates[key] = raw
|
|
773
|
+
elif raw is not None:
|
|
774
|
+
updates[key] = str(raw).strip().casefold() in {
|
|
775
|
+
"1",
|
|
776
|
+
"true",
|
|
777
|
+
"yes",
|
|
778
|
+
"on",
|
|
779
|
+
}
|
|
780
|
+
continue
|
|
781
|
+
if key in _INT_PALETTE_KEYS:
|
|
782
|
+
try:
|
|
783
|
+
updates[key] = max(0, int(raw))
|
|
784
|
+
except (TypeError, ValueError):
|
|
785
|
+
pass
|
|
786
|
+
continue
|
|
787
|
+
color = _normalize_color(raw)
|
|
788
|
+
if color is not None:
|
|
789
|
+
updates[key] = color
|
|
790
|
+
return replace(base, **updates)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _parse_themes_blob(blob: dict[str, Any]) -> dict[str, Theme]:
|
|
794
|
+
"""Parse a themes.json root object into name -> Theme."""
|
|
795
|
+
section = blob.get("themes")
|
|
796
|
+
if section is None:
|
|
797
|
+
# Allow flat map of name -> palette (without wrapper).
|
|
798
|
+
section = {
|
|
799
|
+
k: v
|
|
800
|
+
for k, v in blob.items()
|
|
801
|
+
if k not in {"active", "default", "theme"} and isinstance(v, dict)
|
|
802
|
+
}
|
|
803
|
+
if not isinstance(section, dict):
|
|
804
|
+
return {}
|
|
805
|
+
|
|
806
|
+
# Multi-pass so extends can refer to peers defined in the same file.
|
|
807
|
+
pending: dict[str, dict[str, Any]] = {
|
|
808
|
+
str(k).strip(): v for k, v in section.items() if str(k).strip() and isinstance(v, dict)
|
|
809
|
+
}
|
|
810
|
+
built: dict[str, Theme] = {}
|
|
811
|
+
# Prefer pure built-in extends first.
|
|
812
|
+
guard = 0
|
|
813
|
+
while pending and guard < 32:
|
|
814
|
+
guard += 1
|
|
815
|
+
progress = False
|
|
816
|
+
for name in list(pending.keys()):
|
|
817
|
+
data = pending[name]
|
|
818
|
+
extends = str(data.get("extends") or data.get("base") or "").strip()
|
|
819
|
+
if (
|
|
820
|
+
extends
|
|
821
|
+
and extends not in BUILTIN_THEMES
|
|
822
|
+
and extends not in built
|
|
823
|
+
and extends in pending
|
|
824
|
+
):
|
|
825
|
+
continue
|
|
826
|
+
try:
|
|
827
|
+
built[name] = _theme_from_dict(name, data, catalog=built)
|
|
828
|
+
except KeyError:
|
|
829
|
+
continue
|
|
830
|
+
del pending[name]
|
|
831
|
+
progress = True
|
|
832
|
+
if not progress:
|
|
833
|
+
break
|
|
834
|
+
# Last attempt: force-build remaining against defaults / partial catalog.
|
|
835
|
+
for name, data in list(pending.items()):
|
|
836
|
+
try:
|
|
837
|
+
built[name] = _theme_from_dict(name, data, catalog=built)
|
|
838
|
+
except Exception: # noqa: BLE001
|
|
839
|
+
continue
|
|
840
|
+
return built
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def load_custom_themes(workspace: Path | str | None = None) -> dict[str, Theme]:
|
|
844
|
+
"""Load layered ``themes.json`` (user → project). Later layers override."""
|
|
845
|
+
merged, _paths = load_layered_json(THEMES_FILENAME, workspace)
|
|
846
|
+
if not merged:
|
|
847
|
+
return {}
|
|
848
|
+
return _parse_themes_blob(merged)
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def reload_theme_catalog(workspace: Path | str | None = None) -> dict[str, Theme]:
|
|
852
|
+
"""Refresh the in-memory custom catalog from disk."""
|
|
853
|
+
global _custom, _loaded_workspace
|
|
854
|
+
_custom = load_custom_themes(workspace)
|
|
855
|
+
try:
|
|
856
|
+
_loaded_workspace = str(Path(workspace).resolve()) if workspace is not None else None
|
|
857
|
+
except Exception: # noqa: BLE001
|
|
858
|
+
_loaded_workspace = str(workspace) if workspace is not None else None
|
|
859
|
+
return dict(_custom)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _notify(theme: Theme) -> None:
|
|
863
|
+
for cb in list(_listeners):
|
|
864
|
+
try:
|
|
865
|
+
cb(theme)
|
|
866
|
+
except Exception: # noqa: BLE001
|
|
867
|
+
continue
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def set_active_theme(theme: Theme) -> Theme:
|
|
871
|
+
"""Set the process-wide active theme and notify listeners."""
|
|
872
|
+
global _active
|
|
873
|
+
_active = theme
|
|
874
|
+
_notify(theme)
|
|
875
|
+
return theme
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
# User-facing aliases that map to the terminal-inherit palette.
|
|
879
|
+
_THEME_ALIASES: dict[str, str] = {
|
|
880
|
+
"inherit": "ansi",
|
|
881
|
+
"terminal": "ansi",
|
|
882
|
+
"auto": "ansi",
|
|
883
|
+
"default": "ansi",
|
|
884
|
+
"transparent": "ansi",
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
# Textual App.theme names registered by ensure_textual_themes().
|
|
888
|
+
TEXTUAL_THEME_ANSI = "synapse-ansi"
|
|
889
|
+
TEXTUAL_THEME_DARK = "synapse-dark"
|
|
890
|
+
TEXTUAL_THEME_LIGHT = "synapse-light"
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def resolve_theme_name(name: str | None) -> str:
|
|
894
|
+
text = (name or "").strip()
|
|
895
|
+
if not text:
|
|
896
|
+
return DEFAULT_THEME_NAME
|
|
897
|
+
key = text.casefold()
|
|
898
|
+
if key in _THEME_ALIASES:
|
|
899
|
+
return _THEME_ALIASES[key]
|
|
900
|
+
if text in BUILTIN_THEMES or text in _custom:
|
|
901
|
+
return text
|
|
902
|
+
for candidate in list(BUILTIN_THEMES) + list(_custom):
|
|
903
|
+
if candidate.casefold() == key:
|
|
904
|
+
return candidate
|
|
905
|
+
return text
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _is_light_hex(color: str) -> bool:
|
|
909
|
+
"""Rough relative-luminance check for solid #rrggbb backgrounds."""
|
|
910
|
+
c = (color or "").strip().lstrip("#")
|
|
911
|
+
if len(c) < 6:
|
|
912
|
+
return False
|
|
913
|
+
try:
|
|
914
|
+
r, g, b = int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)
|
|
915
|
+
except ValueError:
|
|
916
|
+
return False
|
|
917
|
+
lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
|
|
918
|
+
return lum > 0.5
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def theme_kind(theme: Theme | None = None) -> str:
|
|
922
|
+
"""Classify a palette as ``ansi``, ``light``, or ``dark``."""
|
|
923
|
+
t = theme or get_theme()
|
|
924
|
+
if t.is_terminal_inherit:
|
|
925
|
+
return "ansi"
|
|
926
|
+
return "light" if _is_light_hex(t.bg) else "dark"
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def textual_themes() -> list[Any]:
|
|
930
|
+
"""Build Textual Theme objects for ``App.register_theme``."""
|
|
931
|
+
from textual.theme import Theme as TextualTheme
|
|
932
|
+
|
|
933
|
+
coding_ansi = TextualTheme(
|
|
934
|
+
name=TEXTUAL_THEME_ANSI,
|
|
935
|
+
primary="ansi_cyan",
|
|
936
|
+
secondary="ansi_blue",
|
|
937
|
+
warning="ansi_yellow",
|
|
938
|
+
error="ansi_red",
|
|
939
|
+
success="ansi_green",
|
|
940
|
+
accent="ansi_cyan",
|
|
941
|
+
foreground="ansi_default",
|
|
942
|
+
background="transparent",
|
|
943
|
+
surface="transparent",
|
|
944
|
+
panel="transparent",
|
|
945
|
+
boost="transparent",
|
|
946
|
+
dark=True,
|
|
947
|
+
ansi=True,
|
|
948
|
+
variables={
|
|
949
|
+
# Built-in ansi-dark paints solid black; keep acrylic/wallpaper.
|
|
950
|
+
"ansi-background": "transparent",
|
|
951
|
+
"ansi-foreground": "ansi_default",
|
|
952
|
+
"border-blurred": "ansi_bright_black",
|
|
953
|
+
"input-cursor-background": "ansi_default",
|
|
954
|
+
"input-cursor-foreground": "ansi_default",
|
|
955
|
+
"input-selection-background": "ansi_bright_blue",
|
|
956
|
+
"input-selection-foreground": "ansi_black",
|
|
957
|
+
"footer-background": "transparent",
|
|
958
|
+
},
|
|
959
|
+
)
|
|
960
|
+
coding_dark = TextualTheme(
|
|
961
|
+
name=TEXTUAL_THEME_DARK,
|
|
962
|
+
primary="#89b4fa",
|
|
963
|
+
secondary="#cba6f7",
|
|
964
|
+
warning="#f4b183",
|
|
965
|
+
error="#f38ba8",
|
|
966
|
+
success="#81c995",
|
|
967
|
+
accent="#89b4fa",
|
|
968
|
+
foreground="#e8eaed",
|
|
969
|
+
background="#1a1b1e",
|
|
970
|
+
surface="#1a1b1e",
|
|
971
|
+
panel="#121316",
|
|
972
|
+
boost="#2b2d31",
|
|
973
|
+
dark=True,
|
|
974
|
+
ansi=False,
|
|
975
|
+
)
|
|
976
|
+
coding_light = TextualTheme(
|
|
977
|
+
name=TEXTUAL_THEME_LIGHT,
|
|
978
|
+
primary="#0969da",
|
|
979
|
+
secondary="#8250df",
|
|
980
|
+
warning="#9a6700",
|
|
981
|
+
error="#cf222e",
|
|
982
|
+
success="#1a7f37",
|
|
983
|
+
accent="#0969da",
|
|
984
|
+
foreground="#1f2328",
|
|
985
|
+
background="#f6f8fa",
|
|
986
|
+
surface="#ffffff",
|
|
987
|
+
panel="#ffffff",
|
|
988
|
+
boost="#eaeef2",
|
|
989
|
+
dark=False,
|
|
990
|
+
ansi=False,
|
|
991
|
+
)
|
|
992
|
+
return [coding_ansi, coding_dark, coding_light]
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def ensure_textual_themes(app: Any) -> None:
|
|
996
|
+
"""Register synapse-* Textual themes on an App instance (idempotent)."""
|
|
997
|
+
for th in textual_themes():
|
|
998
|
+
try:
|
|
999
|
+
app.register_theme(th)
|
|
1000
|
+
except Exception: # noqa: BLE001
|
|
1001
|
+
pass
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def apply_textual_theme(app: Any, theme: Theme | None = None) -> str:
|
|
1005
|
+
"""Switch ``App.theme`` so Textual surfaces match the active palette.
|
|
1006
|
+
|
|
1007
|
+
Returns the Textual theme name applied (or attempted).
|
|
1008
|
+
"""
|
|
1009
|
+
pal = theme or get_theme()
|
|
1010
|
+
kind = theme_kind(pal)
|
|
1011
|
+
if kind == "ansi":
|
|
1012
|
+
name = TEXTUAL_THEME_ANSI
|
|
1013
|
+
elif kind == "light":
|
|
1014
|
+
name = TEXTUAL_THEME_LIGHT
|
|
1015
|
+
else:
|
|
1016
|
+
name = TEXTUAL_THEME_DARK
|
|
1017
|
+
ensure_textual_themes(app)
|
|
1018
|
+
try:
|
|
1019
|
+
app.theme = name
|
|
1020
|
+
return name
|
|
1021
|
+
except Exception: # noqa: BLE001
|
|
1022
|
+
fallback = {
|
|
1023
|
+
TEXTUAL_THEME_ANSI: "ansi-dark",
|
|
1024
|
+
TEXTUAL_THEME_DARK: "textual-dark",
|
|
1025
|
+
TEXTUAL_THEME_LIGHT: "textual-light",
|
|
1026
|
+
}.get(name, "textual-dark")
|
|
1027
|
+
try:
|
|
1028
|
+
app.theme = fallback
|
|
1029
|
+
except Exception: # noqa: BLE001
|
|
1030
|
+
pass
|
|
1031
|
+
return fallback
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def set_theme(
|
|
1035
|
+
name: str | None,
|
|
1036
|
+
*,
|
|
1037
|
+
workspace: Path | str | None = None,
|
|
1038
|
+
persist: bool = False,
|
|
1039
|
+
scope: str = "user",
|
|
1040
|
+
reload: bool = True,
|
|
1041
|
+
) -> Theme:
|
|
1042
|
+
"""Activate a theme by name. Optionally persist to settings.json."""
|
|
1043
|
+
if reload:
|
|
1044
|
+
reload_theme_catalog(workspace)
|
|
1045
|
+
key = resolve_theme_name(name)
|
|
1046
|
+
try:
|
|
1047
|
+
theme = get_theme_by_name(key)
|
|
1048
|
+
except KeyError as exc:
|
|
1049
|
+
raise KeyError(
|
|
1050
|
+
f"unknown theme: {key!r}. available: {', '.join(list_theme_names())}"
|
|
1051
|
+
) from exc
|
|
1052
|
+
set_active_theme(theme)
|
|
1053
|
+
if persist:
|
|
1054
|
+
persist_theme_preference(theme.name, workspace=workspace, scope=scope)
|
|
1055
|
+
return theme
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def bootstrap_theme(
|
|
1059
|
+
name: str | None = None,
|
|
1060
|
+
*,
|
|
1061
|
+
workspace: Path | str | None = None,
|
|
1062
|
+
) -> Theme:
|
|
1063
|
+
"""Load customs + activate ``name`` (fallback: default). Never raises on bad name."""
|
|
1064
|
+
reload_theme_catalog(workspace)
|
|
1065
|
+
key = resolve_theme_name(name)
|
|
1066
|
+
try:
|
|
1067
|
+
return set_theme(key, workspace=workspace, persist=False, reload=False)
|
|
1068
|
+
except KeyError:
|
|
1069
|
+
return set_theme(DEFAULT_THEME_NAME, workspace=workspace, persist=False, reload=False)
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def themes_config_paths(workspace: Path | str | None = None) -> list[Path]:
|
|
1073
|
+
return existing_files(layered_config_dirs(workspace), THEMES_FILENAME)
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def persist_theme_preference(
|
|
1077
|
+
name: str,
|
|
1078
|
+
*,
|
|
1079
|
+
workspace: Path | str | None = None,
|
|
1080
|
+
scope: str = "user",
|
|
1081
|
+
) -> Path:
|
|
1082
|
+
"""Write ``theme`` into user or project ``settings.json`` (merge, keep other keys)."""
|
|
1083
|
+
key = resolve_theme_name(name)
|
|
1084
|
+
if scope == "project":
|
|
1085
|
+
target_dir = project_config_dir(workspace)
|
|
1086
|
+
else:
|
|
1087
|
+
target_dir = user_config_dir()
|
|
1088
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
1089
|
+
path = target_dir / SETTINGS_FILENAME
|
|
1090
|
+
data: dict[str, Any] = {}
|
|
1091
|
+
if path.is_file():
|
|
1092
|
+
try:
|
|
1093
|
+
data = load_json_object(path)
|
|
1094
|
+
except Exception: # noqa: BLE001
|
|
1095
|
+
data = {}
|
|
1096
|
+
data["theme"] = key
|
|
1097
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
1098
|
+
return path
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def format_theme_list_lines(*, active: str | None = None) -> list[str]:
|
|
1102
|
+
"""Human-readable theme catalog for ``/theme``."""
|
|
1103
|
+
current = (active or get_theme().name).strip() or DEFAULT_THEME_NAME
|
|
1104
|
+
lines = [f"theme: {current}", "available:"]
|
|
1105
|
+
for theme in list_themes():
|
|
1106
|
+
mark = "*" if theme.name == current else " "
|
|
1107
|
+
tone = theme_kind(theme)
|
|
1108
|
+
if theme.name in _custom and theme.name not in BUILTIN_THEMES:
|
|
1109
|
+
kind = f"custom/{tone}"
|
|
1110
|
+
elif theme.name in _custom:
|
|
1111
|
+
kind = f"override/{tone}"
|
|
1112
|
+
else:
|
|
1113
|
+
kind = f"built-in/{tone}"
|
|
1114
|
+
lines.append(f" {mark} {theme.name:20} {theme.label} ({kind})")
|
|
1115
|
+
lines.append("usage: /theme <name>")
|
|
1116
|
+
lines.append(" /theme list")
|
|
1117
|
+
lines.append(" /theme ansi|inherit (terminal transparent)")
|
|
1118
|
+
lines.append("config: settings.json theme + optional themes.json")
|
|
1119
|
+
return lines
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def theme_field_names() -> list[str]:
|
|
1123
|
+
return [f.name for f in fields(Theme)]
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
__all__ = [
|
|
1127
|
+
"BUILTIN_THEMES",
|
|
1128
|
+
"DEFAULT_THEME_NAME",
|
|
1129
|
+
"TEXTUAL_THEME_ANSI",
|
|
1130
|
+
"TEXTUAL_THEME_DARK",
|
|
1131
|
+
"TEXTUAL_THEME_LIGHT",
|
|
1132
|
+
"THEMES_FILENAME",
|
|
1133
|
+
"Theme",
|
|
1134
|
+
"apply_textual_theme",
|
|
1135
|
+
"bootstrap_theme",
|
|
1136
|
+
"builtin_theme_names",
|
|
1137
|
+
"ensure_textual_themes",
|
|
1138
|
+
"format_theme_list_lines",
|
|
1139
|
+
"get_theme",
|
|
1140
|
+
"get_theme_by_name",
|
|
1141
|
+
"list_theme_names",
|
|
1142
|
+
"list_themes",
|
|
1143
|
+
"load_custom_themes",
|
|
1144
|
+
"on_theme_change",
|
|
1145
|
+
"persist_theme_preference",
|
|
1146
|
+
"reload_theme_catalog",
|
|
1147
|
+
"resolve_theme_name",
|
|
1148
|
+
"set_active_theme",
|
|
1149
|
+
"set_theme",
|
|
1150
|
+
"textual_themes",
|
|
1151
|
+
"theme_field_names",
|
|
1152
|
+
"theme_kind",
|
|
1153
|
+
"themes_config_paths",
|
|
1154
|
+
]
|