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.
Files changed (131) hide show
  1. synapse/__init__.py +13 -0
  2. synapse/__main__.py +6 -0
  3. synapse/app/__init__.py +1 -0
  4. synapse/app/agent.py +492 -0
  5. synapse/app/agent_md.py +107 -0
  6. synapse/cli.py +750 -0
  7. synapse/commands/__init__.py +1 -0
  8. synapse/commands/compression.py +573 -0
  9. synapse/commands/helpers.py +22 -0
  10. synapse/commands/mcp.py +406 -0
  11. synapse/commands/model.py +173 -0
  12. synapse/commands/result.py +34 -0
  13. synapse/commands/sessions.py +443 -0
  14. synapse/commands/slash_cmds.py +521 -0
  15. synapse/commands/slash_complete.py +816 -0
  16. synapse/commands/theme.py +99 -0
  17. synapse/config.py +27 -0
  18. synapse/content/__init__.py +1 -0
  19. synapse/content/input_history.py +122 -0
  20. synapse/content/multimodal.py +733 -0
  21. synapse/content/prompts.py +249 -0
  22. synapse/content/skills_catalog.py +128 -0
  23. synapse/integrations/__init__.py +1 -0
  24. synapse/integrations/checkpoint_seed.py +281 -0
  25. synapse/integrations/codex_history.py +375 -0
  26. synapse/integrations/codex_import.py +393 -0
  27. synapse/integrations/codex_sessions.py +629 -0
  28. synapse/integrations/describe_image.py +370 -0
  29. synapse/integrations/http_clients.py +199 -0
  30. synapse/integrations/llm_openai_compat.py +90 -0
  31. synapse/integrations/llm_openai_websocket.py +187 -0
  32. synapse/integrations/mcp_client.py +646 -0
  33. synapse/integrations/vision_middleware.py +62 -0
  34. synapse/models/__init__.py +5 -0
  35. synapse/models/config.py +240 -0
  36. synapse/models/helpers.py +206 -0
  37. synapse/models/profile.py +59 -0
  38. synapse/models/registry.py +722 -0
  39. synapse/models_registry.py +7 -0
  40. synapse/observability/__init__.py +1 -0
  41. synapse/observability/startup_trace.py +127 -0
  42. synapse/runtime/__init__.py +1 -0
  43. synapse/runtime/async_runtime.py +176 -0
  44. synapse/runtime/backends.py +458 -0
  45. synapse/runtime/context_compact.py +249 -0
  46. synapse/runtime/execute_capture.py +48 -0
  47. synapse/runtime/fs_permissions.py +79 -0
  48. synapse/runtime/harness.py +57 -0
  49. synapse/runtime/hitl.py +197 -0
  50. synapse/runtime/interaction_ledger.py +82 -0
  51. synapse/runtime/middleware.py +802 -0
  52. synapse/runtime/model_request_compression_middleware.py +745 -0
  53. synapse/runtime/pathing.py +146 -0
  54. synapse/runtime/safety.py +184 -0
  55. synapse/runtime/steer.py +240 -0
  56. synapse/runtime/subagents.py +207 -0
  57. synapse/runtime/tool_ignore.py +221 -0
  58. synapse/runtime/tool_output_eval.py +118 -0
  59. synapse/runtime/tool_output_middleware.py +585 -0
  60. synapse/runtime/tool_output_usage_middleware.py +60 -0
  61. synapse/sessions/__init__.py +31 -0
  62. synapse/sessions/cancel_repair.py +208 -0
  63. synapse/sessions/session_recap.py +174 -0
  64. synapse/sessions/store.py +695 -0
  65. synapse/sessions/transcript.py +754 -0
  66. synapse/settings/__init__.py +5 -0
  67. synapse/settings/config_paths.py +184 -0
  68. synapse/settings/schema.py +464 -0
  69. synapse/tool_output/__init__.py +59 -0
  70. synapse/tool_output/detection.py +170 -0
  71. synapse/tool_output/metrics.py +32 -0
  72. synapse/tool_output/models.py +173 -0
  73. synapse/tool_output/pipeline.py +330 -0
  74. synapse/tool_output/repository.py +721 -0
  75. synapse/tool_output/transformers.py +648 -0
  76. synapse/tools/__init__.py +5 -0
  77. synapse/tools/session_tools.py +204 -0
  78. synapse/ui/__init__.py +10 -0
  79. synapse/ui/bottombar/__init__.py +73 -0
  80. synapse/ui/bottombar/components/__init__.py +143 -0
  81. synapse/ui/bottombar/components/key_hints.py +30 -0
  82. synapse/ui/bottombar/components/mcp.py +64 -0
  83. synapse/ui/bottombar/components/mode.py +24 -0
  84. synapse/ui/bottombar/components/model.py +28 -0
  85. synapse/ui/bottombar/components/thread.py +29 -0
  86. synapse/ui/bottombar/context.py +36 -0
  87. synapse/ui/bottombar/core.py +74 -0
  88. synapse/ui/dialogs/__init__.py +25 -0
  89. synapse/ui/dialogs/base.py +362 -0
  90. synapse/ui/dialogs/codex_session_list.py +84 -0
  91. synapse/ui/dialogs/compression_diagnostics.py +210 -0
  92. synapse/ui/dialogs/git_explore.py +702 -0
  93. synapse/ui/dialogs/mcp_panel.py +407 -0
  94. synapse/ui/dialogs/model_picker.py +128 -0
  95. synapse/ui/dialogs/safety_panel.py +63 -0
  96. synapse/ui/dialogs/session_list.py +98 -0
  97. synapse/ui/dialogs/theme_designer.py +863 -0
  98. synapse/ui/dialogs/theme_picker.py +113 -0
  99. synapse/ui/git_explore/__init__.py +31 -0
  100. synapse/ui/git_explore/engine.py +82 -0
  101. synapse/ui/git_explore/provider.py +242 -0
  102. synapse/ui/git_explore/unified.py +85 -0
  103. synapse/ui/rendering.py +350 -0
  104. synapse/ui/sink.py +70 -0
  105. synapse/ui/steer_widget.py +367 -0
  106. synapse/ui/stream.py +1207 -0
  107. synapse/ui/stream_events.py +421 -0
  108. synapse/ui/stream_runtime.py +252 -0
  109. synapse/ui/theme.py +1154 -0
  110. synapse/ui/timeline.py +621 -0
  111. synapse/ui/topbar/__init__.py +97 -0
  112. synapse/ui/topbar/components/__init__.py +150 -0
  113. synapse/ui/topbar/components/branch.py +41 -0
  114. synapse/ui/topbar/components/title.py +24 -0
  115. synapse/ui/topbar/components/tool_output.py +24 -0
  116. synapse/ui/topbar/components/usage.py +24 -0
  117. synapse/ui/topbar/components/workspace.py +32 -0
  118. synapse/ui/topbar/context.py +32 -0
  119. synapse/ui/topbar/core.py +979 -0
  120. synapse/ui/topbar/git_changes_popover.py +178 -0
  121. synapse/ui/topbar/git_chrome.py +475 -0
  122. synapse/ui/topbar/tool_output_popover.py +84 -0
  123. synapse/ui/topbar/widget.py +474 -0
  124. synapse/ui/tui.py +5717 -0
  125. synapse/ui/turn_rail.py +71 -0
  126. synapse/ui/user_turn.py +83 -0
  127. synapse/ui/welcome.py +261 -0
  128. synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
  129. synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
  130. synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
  131. synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,863 @@
1
+ """Theme designer dialog — compact HSV picker with live preview."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import colorsys
6
+ import json
7
+ import re
8
+ from typing import Any
9
+
10
+ from rich.style import Style
11
+ from rich.text import Text
12
+ from textual import on
13
+ from textual.app import ComposeResult
14
+ from textual.binding import Binding
15
+ from textual.containers import Horizontal, Vertical, VerticalScroll
16
+ from textual.events import Click
17
+ from textual.message import Message
18
+ from textual.screen import ModalScreen
19
+ from textual.timer import Timer
20
+ from textual.widgets import Input, Label, Select, Static
21
+
22
+ from synapse.settings.config_paths import user_config_dir
23
+ from synapse.ui.theme import (
24
+ THEMES_FILENAME,
25
+ _theme_from_dict,
26
+ list_theme_names,
27
+ reload_theme_catalog,
28
+ set_theme,
29
+ )
30
+
31
+ _COLOR_FIELDS: list[tuple[str, str]] = [
32
+ ("bg", "Background"),
33
+ ("top", "Top bar"),
34
+ ("bar", "Status bar"),
35
+ ("fg", "Foreground"),
36
+ ("dim", "Dim text"),
37
+ ("muted", "Muted text"),
38
+ ("green", "Success"),
39
+ ("orange", "Warning"),
40
+ ("user", "User accent"),
41
+ ("border", "Border"),
42
+ ("border_focus", "Focus border"),
43
+ ("error", "Error"),
44
+ ("top_left", "Top left"),
45
+ ("top_center", "Top center"),
46
+ ("top_right", "Top right"),
47
+ ]
48
+ _COLOR_LABELS = dict(_COLOR_FIELDS)
49
+ _BORDER_STYLES = ("tall", "heavy", "solid", "round", "dashed", "double", "panel")
50
+ _HEX_RE = re.compile(r"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
51
+ _PREVIEW_DEBOUNCE_S = 0.18
52
+ _PLANE_WIDTH = 30
53
+ _PLANE_HEIGHT = 8
54
+
55
+
56
+ def _normalize_hex(value: str) -> str | None:
57
+ color = (value or "").strip()
58
+ if not _HEX_RE.fullmatch(color):
59
+ return None
60
+ if len(color) == 4:
61
+ color = f"#{color[1] * 2}{color[2] * 2}{color[3] * 2}"
62
+ return color.lower()
63
+
64
+
65
+ def _hex_to_hsv(value: str) -> tuple[float, float, float] | None:
66
+ color = _normalize_hex(value)
67
+ if color is None:
68
+ return None
69
+ red, green, blue = (int(color[index : index + 2], 16) / 255 for index in (1, 3, 5))
70
+ return colorsys.rgb_to_hsv(red, green, blue)
71
+
72
+
73
+ def _hsv_to_hex(hue: float, saturation: float, value: float) -> str:
74
+ red, green, blue = colorsys.hsv_to_rgb(
75
+ hue % 1.0,
76
+ max(0.0, min(1.0, saturation)),
77
+ max(0.0, min(1.0, value)),
78
+ )
79
+ return f"#{round(red * 255):02x}{round(green * 255):02x}{round(blue * 255):02x}"
80
+
81
+
82
+ def _marker_color(value: float) -> str:
83
+ return "black" if value > 0.62 else "white"
84
+
85
+
86
+ class _DesignerColorChanged(Message):
87
+ """A theme color changed, from the picker or a direct programmatic update."""
88
+
89
+ def __init__(self, key: str, value: str) -> None:
90
+ super().__init__()
91
+ self.key = key
92
+ self.value = value
93
+
94
+
95
+ class _ColorRoleSelected(Message):
96
+ def __init__(self, key: str) -> None:
97
+ super().__init__()
98
+ self.key = key
99
+
100
+
101
+ class _PickerColorChanged(Message):
102
+ def __init__(self, value: str) -> None:
103
+ super().__init__()
104
+ self.value = value
105
+
106
+
107
+ class _ColorRoleRow(Static):
108
+ """One compact palette role row."""
109
+
110
+ def __init__(self, key: str, label: str, value: str) -> None:
111
+ super().__init__("", id=f"role-{key}")
112
+ self.key = key
113
+ self.label = label
114
+ self.value = value
115
+ self._selected = False
116
+ self._paint()
117
+
118
+ def set_value(self, value: str) -> None:
119
+ self.value = value
120
+ self._paint()
121
+
122
+ def set_selected(self, selected: bool) -> None:
123
+ self._selected = selected
124
+ self.set_class(selected, "-selected")
125
+ self._paint()
126
+
127
+ def _paint(self) -> None:
128
+ color = _normalize_hex(self.value)
129
+ row = Text("> " if self._selected else " ")
130
+ if color:
131
+ row.append("●", style=Style(color=color))
132
+ else:
133
+ row.append("○", style="dim")
134
+ row.append(f" {self.label:<13}")
135
+ row.append(color or "inherit", style="dim")
136
+ self.update(row)
137
+
138
+
139
+ class _ColorRoleList(VerticalScroll):
140
+ """Keyboard and mouse selectable list of theme color roles."""
141
+
142
+ can_focus = True
143
+ BINDINGS = [
144
+ Binding("up", "previous", "Previous", show=False),
145
+ Binding("down", "next", "Next", show=False),
146
+ Binding("enter", "choose", "Choose", show=False),
147
+ ]
148
+
149
+ def __init__(self, values: dict[str, str]) -> None:
150
+ super().__init__(id="color-roles")
151
+ self._rows = [
152
+ _ColorRoleRow(key, label, values.get(key, "")) for key, label in _COLOR_FIELDS
153
+ ]
154
+ self._selected_index = 0
155
+
156
+ def compose(self) -> ComposeResult:
157
+ yield from self._rows
158
+
159
+ @property
160
+ def selected_key(self) -> str:
161
+ return self._rows[self._selected_index].key
162
+
163
+ def on_mount(self) -> None:
164
+ self._sync_selection(emit=False)
165
+
166
+ def select_key(self, key: str, *, emit: bool = True) -> None:
167
+ for index, row in enumerate(self._rows):
168
+ if row.key == key:
169
+ self._selected_index = index
170
+ self._sync_selection(emit=emit)
171
+ return
172
+
173
+ def update_value(self, key: str, value: str) -> None:
174
+ for row in self._rows:
175
+ if row.key == key:
176
+ row.set_value(value)
177
+ return
178
+
179
+ def _sync_selection(self, *, emit: bool = True) -> None:
180
+ for index, row in enumerate(self._rows):
181
+ row.set_selected(index == self._selected_index)
182
+ selected = self._rows[self._selected_index]
183
+ self.scroll_to_widget(selected, animate=False)
184
+ if emit:
185
+ self.post_message(_ColorRoleSelected(selected.key))
186
+
187
+ def action_previous(self) -> None:
188
+ if self._selected_index > 0:
189
+ self._selected_index -= 1
190
+ self._sync_selection()
191
+
192
+ def action_next(self) -> None:
193
+ if self._selected_index < len(self._rows) - 1:
194
+ self._selected_index += 1
195
+ self._sync_selection()
196
+
197
+ def action_choose(self) -> None:
198
+ self.post_message(_ColorRoleSelected(self.selected_key))
199
+
200
+ def on_click(self, event: Click) -> None:
201
+ if isinstance(event.widget, _ColorRoleRow):
202
+ self.select_key(event.widget.key)
203
+ self.focus()
204
+ event.stop()
205
+
206
+
207
+ class _ColorPlane(Static):
208
+ """Saturation/value plane for the selected hue."""
209
+
210
+ can_focus = True
211
+ BINDINGS = [
212
+ Binding("left", "left", "Less saturation", show=False),
213
+ Binding("right", "right", "More saturation", show=False),
214
+ Binding("up", "up", "Brighter", show=False),
215
+ Binding("down", "down", "Darker", show=False),
216
+ ]
217
+
218
+ def __init__(self) -> None:
219
+ super().__init__(id="color-plane")
220
+ self.hue = 0.0
221
+ self.saturation = 0.0
222
+ self.value = 0.5
223
+ self._paint()
224
+
225
+ def set_hsv(self, hue: float, saturation: float, value: float, *, emit: bool = False) -> None:
226
+ self.hue = hue % 1.0
227
+ self.saturation = max(0.0, min(1.0, saturation))
228
+ self.value = max(0.0, min(1.0, value))
229
+ self._paint()
230
+ if emit:
231
+ self._emit_color()
232
+
233
+ def _paint(self) -> None:
234
+ selected_x = round(self.saturation * (_PLANE_WIDTH - 1))
235
+ selected_y = round((1.0 - self.value) * (_PLANE_HEIGHT - 1))
236
+ canvas = Text()
237
+ for y in range(_PLANE_HEIGHT):
238
+ value = 1.0 - (y / (_PLANE_HEIGHT - 1))
239
+ for x in range(_PLANE_WIDTH):
240
+ saturation = x / (_PLANE_WIDTH - 1)
241
+ color = _hsv_to_hex(self.hue, saturation, value)
242
+ marker = "◆" if x == selected_x and y == selected_y else " "
243
+ canvas.append(marker, style=Style(color=_marker_color(value), bgcolor=color))
244
+ if y < _PLANE_HEIGHT - 1:
245
+ canvas.append("\n")
246
+ self.update(canvas)
247
+
248
+ def _emit_color(self) -> None:
249
+ self.post_message(_PickerColorChanged(_hsv_to_hex(self.hue, self.saturation, self.value)))
250
+
251
+ def _move(self, dx: int = 0, dy: int = 0) -> None:
252
+ x = max(0, min(_PLANE_WIDTH - 1, round(self.saturation * (_PLANE_WIDTH - 1)) + dx))
253
+ y = max(0, min(_PLANE_HEIGHT - 1, round((1.0 - self.value) * (_PLANE_HEIGHT - 1)) + dy))
254
+ self.set_hsv(self.hue, x / (_PLANE_WIDTH - 1), 1.0 - y / (_PLANE_HEIGHT - 1), emit=True)
255
+
256
+ def action_left(self) -> None:
257
+ self._move(dx=-1)
258
+
259
+ def action_right(self) -> None:
260
+ self._move(dx=1)
261
+
262
+ def action_up(self) -> None:
263
+ self._move(dy=-1)
264
+
265
+ def action_down(self) -> None:
266
+ self._move(dy=1)
267
+
268
+ def on_click(self, event: Click) -> None:
269
+ x = int(event.x - self.content_offset.x)
270
+ y = int(event.y - self.content_offset.y)
271
+ if 0 <= x < _PLANE_WIDTH and 0 <= y < _PLANE_HEIGHT:
272
+ self.set_hsv(self.hue, x / (_PLANE_WIDTH - 1), 1.0 - y / (_PLANE_HEIGHT - 1), emit=True)
273
+ self.focus()
274
+ event.stop()
275
+
276
+
277
+ class _HueStrip(Static):
278
+ """Hue selector paired with :class:`_ColorPlane`."""
279
+
280
+ can_focus = True
281
+ BINDINGS = [
282
+ Binding("left", "left", "Previous hue", show=False),
283
+ Binding("right", "right", "Next hue", show=False),
284
+ ]
285
+
286
+ def __init__(self) -> None:
287
+ super().__init__(id="hue-strip")
288
+ self.hue = 0.0
289
+ self._paint()
290
+
291
+ def set_hue(self, hue: float, *, emit: bool = False) -> None:
292
+ self.hue = hue % 1.0
293
+ self._paint()
294
+ if emit:
295
+ self.post_message(_PickerColorChanged(""))
296
+
297
+ def _paint(self) -> None:
298
+ selected_x = round(self.hue * (_PLANE_WIDTH - 1))
299
+ strip = Text()
300
+ for x in range(_PLANE_WIDTH):
301
+ hue = x / _PLANE_WIDTH
302
+ color = _hsv_to_hex(hue, 1.0, 1.0)
303
+ strip.append("◆" if x == selected_x else " ", style=Style(color="black", bgcolor=color))
304
+ self.update(strip)
305
+
306
+ def _move(self, delta: int) -> None:
307
+ x = (round(self.hue * (_PLANE_WIDTH - 1)) + delta) % _PLANE_WIDTH
308
+ self.set_hue(x / (_PLANE_WIDTH - 1), emit=True)
309
+
310
+ def action_left(self) -> None:
311
+ self._move(-1)
312
+
313
+ def action_right(self) -> None:
314
+ self._move(1)
315
+
316
+ def on_click(self, event: Click) -> None:
317
+ x = int(event.x - self.content_offset.x)
318
+ if 0 <= x < _PLANE_WIDTH:
319
+ self.set_hue(x / (_PLANE_WIDTH - 1), emit=True)
320
+ self.focus()
321
+ event.stop()
322
+
323
+
324
+ class _ColorReadout(Static):
325
+ def __init__(self) -> None:
326
+ super().__init__("", id="color-readout")
327
+
328
+ def set_color(self, label: str, value: str) -> None:
329
+ color = _normalize_hex(value)
330
+ text = Text(f"{label} ", style="bold")
331
+ if color:
332
+ text.append(" ", style=Style(bgcolor=color))
333
+ text.append(f" {color}", style="bold")
334
+ else:
335
+ text.append("○ inherited from base", style="dim")
336
+ self.update(text)
337
+
338
+
339
+ class ThemeDesignerDialog(ModalScreen[Any]):
340
+ """Theme editor aligned with the app's standard modal visual language."""
341
+
342
+ DEFAULT_CSS = """
343
+ ThemeDesignerDialog {
344
+ align: center middle;
345
+ /* The inline style in __init__ guarantees this wins over Screen's
346
+ inherited default background at runtime. */
347
+ background: transparent;
348
+ }
349
+ ThemeDesignerDialog > #designer-window {
350
+ layer: overlay;
351
+ width: 78;
352
+ max-width: 96%;
353
+ height: 27;
354
+ max-height: 94%;
355
+ background: $theme-bg;
356
+ border: round $theme-user;
357
+ border-title-color: $theme-fg;
358
+ border-title-background: $theme-top;
359
+ border-title-style: bold;
360
+ border-title-align: left;
361
+ border-subtitle-color: $theme-muted;
362
+ border-subtitle-align: right;
363
+ padding: 0 1;
364
+ layout: vertical;
365
+ }
366
+ ThemeDesignerDialog .section-title {
367
+ height: 1;
368
+ color: $theme-orange;
369
+ text-style: bold;
370
+ margin-top: 1;
371
+ }
372
+ #designer-meta {
373
+ height: 2;
374
+ }
375
+ ThemeDesignerDialog .meta-row {
376
+ height: 1;
377
+ width: 1fr;
378
+ align-vertical: middle;
379
+ }
380
+ ThemeDesignerDialog .meta-label {
381
+ width: 7;
382
+ color: $theme-dim;
383
+ content-align: left middle;
384
+ }
385
+ ThemeDesignerDialog .meta-gap {
386
+ width: 2;
387
+ }
388
+ ThemeDesignerDialog .meta-control {
389
+ width: 1fr;
390
+ height: 1;
391
+ background: $theme-bar;
392
+ color: $theme-fg;
393
+ padding: 0 1;
394
+ }
395
+ ThemeDesignerDialog Input.meta-control:focus {
396
+ background: $theme-top;
397
+ color: $theme-fg;
398
+ }
399
+ ThemeDesignerDialog Select.meta-control > SelectCurrent {
400
+ height: 1;
401
+ min-height: 1;
402
+ background: $theme-bar;
403
+ color: $theme-fg;
404
+ padding: 0;
405
+ border: none;
406
+ }
407
+ ThemeDesignerDialog Select.meta-control:focus > SelectCurrent {
408
+ background: $theme-top;
409
+ border: none;
410
+ }
411
+ #designer-editor {
412
+ height: 1fr;
413
+ min-height: 15;
414
+ margin-top: 1;
415
+ border-top: solid $theme-border;
416
+ }
417
+ #color-roles {
418
+ width: 33;
419
+ min-width: 27;
420
+ height: 1fr;
421
+ background: $theme-bg;
422
+ scrollbar-size: 0 0;
423
+ overflow-x: hidden;
424
+ }
425
+ #color-roles:focus {
426
+ background: $theme-bg;
427
+ }
428
+ _ColorRoleRow {
429
+ width: 1fr;
430
+ height: 1;
431
+ color: $theme-dim;
432
+ padding: 0 1;
433
+ }
434
+ _ColorRoleRow.-selected {
435
+ color: $theme-fg;
436
+ background: $theme-bar;
437
+ text-style: bold;
438
+ }
439
+ #picker-panel {
440
+ width: 1fr;
441
+ min-width: 34;
442
+ height: 1fr;
443
+ padding-left: 2;
444
+ border-left: solid $theme-border;
445
+ }
446
+ #color-readout {
447
+ height: 2;
448
+ color: $theme-fg;
449
+ content-align: left middle;
450
+ }
451
+ #color-plane {
452
+ width: 32;
453
+ height: 10;
454
+ border: tall $theme-border;
455
+ background: $theme-bg;
456
+ }
457
+ #color-plane:focus {
458
+ border: tall $theme-user;
459
+ }
460
+ #hue-title {
461
+ height: 1;
462
+ margin-top: 1;
463
+ color: $theme-muted;
464
+ }
465
+ #hue-strip {
466
+ width: 32;
467
+ height: 3;
468
+ border: tall $theme-border;
469
+ background: $theme-bg;
470
+ }
471
+ #hue-strip:focus {
472
+ border: tall $theme-user;
473
+ }
474
+ #picker-actions {
475
+ height: 1;
476
+ margin-top: 1;
477
+ }
478
+ #inherit-color {
479
+ width: auto;
480
+ color: $theme-user;
481
+ text-style: bold;
482
+ }
483
+ #inherit-color:hover {
484
+ color: $theme-fg;
485
+ background: $theme-bar;
486
+ }
487
+ #picker-help {
488
+ width: 1fr;
489
+ color: $theme-muted;
490
+ content-align: right middle;
491
+ }
492
+ """
493
+
494
+ BINDINGS = [
495
+ Binding("escape", "cancel", "Cancel", show=False, priority=True),
496
+ Binding("ctrl+s", "save", "Save", show=False, priority=True),
497
+ Binding("delete", "inherit_color", "Use base", show=False),
498
+ ]
499
+
500
+ def __init__(self, settings: Any, project_root: Any = None) -> None:
501
+ super().__init__()
502
+ # ``Screen.DEFAULT_CSS`` may otherwise win the root background cascade
503
+ # on non-ANSI themes and paint an opaque full-screen layer. Inline
504
+ # styles have the required priority while the designer window itself
505
+ # remains opaque via ``#designer-window``.
506
+ self.styles.background = "transparent"
507
+ self._settings = settings
508
+ self._project_root = project_root
509
+
510
+ import synapse.ui.theme as tm
511
+
512
+ current = tm.get_theme()
513
+ self._original = current.name
514
+ self._name = ""
515
+ self._label = ""
516
+ self._extends = current.name if current.name in list_theme_names() else "cursor-dark"
517
+ self._prompt_border = str(getattr(current, "prompt_border", "tall") or "tall")
518
+ if self._prompt_border not in _BORDER_STYLES:
519
+ self._prompt_border = "tall"
520
+ self._code_theme = str(getattr(current, "code_theme", "monokai") or "monokai")
521
+ self._rich_user = str(getattr(current, "rich_user", "") or "")
522
+ self._rich_info_border = str(getattr(current, "rich_info_border", "") or "")
523
+ self._rich_ok_border = str(getattr(current, "rich_ok_border", "") or "")
524
+ self._rich_activity = str(getattr(current, "rich_activity", "") or "")
525
+ self._values = {key: str(getattr(current, key, "") or "") for key, _ in _COLOR_FIELDS}
526
+ self._selected_color_key = _COLOR_FIELDS[0][0]
527
+ self._picker_hsv = _hex_to_hsv(self._values[self._selected_color_key]) or (0.0, 0.0, 0.5)
528
+
529
+ self._preview_seq = 0
530
+ self._preview_busy = False
531
+ self._dismiss_started = False
532
+ self._ready = False
533
+ self._last_preview_sig: str | None = None
534
+ self._preview_timer: Timer | None = None
535
+
536
+ def compose(self) -> ComposeResult:
537
+ theme_names = list_theme_names()
538
+ with Vertical(id="designer-window"):
539
+ yield Static("Theme details", classes="section-title")
540
+ with Vertical(id="designer-meta"):
541
+ with Horizontal(classes="meta-row"):
542
+ yield Label("Name", classes="meta-label")
543
+ yield Input(
544
+ placeholder="my-theme",
545
+ id="meta-name",
546
+ classes="meta-control",
547
+ compact=True,
548
+ )
549
+ yield Static("", classes="meta-gap")
550
+ yield Label("Label", classes="meta-label")
551
+ yield Input(
552
+ placeholder="My Theme",
553
+ id="meta-label",
554
+ classes="meta-control",
555
+ compact=True,
556
+ )
557
+ with Horizontal(classes="meta-row"):
558
+ yield Label("Base", classes="meta-label")
559
+ yield Select(
560
+ [(name, name) for name in theme_names],
561
+ value=self._extends,
562
+ allow_blank=False,
563
+ id="meta-extends",
564
+ classes="meta-control",
565
+ compact=True,
566
+ )
567
+ yield Static("", classes="meta-gap")
568
+ yield Label("Border", classes="meta-label")
569
+ yield Select(
570
+ [(style.title(), style) for style in _BORDER_STYLES],
571
+ value=self._prompt_border,
572
+ allow_blank=False,
573
+ id="meta-prompt-border",
574
+ classes="meta-control",
575
+ compact=True,
576
+ )
577
+ with Horizontal(id="designer-editor"):
578
+ yield _ColorRoleList(self._values)
579
+ with Vertical(id="picker-panel"):
580
+ yield _ColorReadout()
581
+ yield _ColorPlane()
582
+ yield Static("Hue", id="hue-title")
583
+ yield _HueStrip()
584
+ with Horizontal(id="picker-actions"):
585
+ yield Static("Use base color", id="inherit-color")
586
+ yield Static("mouse / arrows", id="picker-help")
587
+
588
+ def on_mount(self) -> None:
589
+ window = self.query_one("#designer-window")
590
+ window.border_title = "◈ Theme Designer"
591
+ window.border_subtitle = "Ctrl+S save · Esc cancel"
592
+ self.query_one("#meta-name", Input).value = self._name
593
+ self.query_one("#meta-label", Input).value = self._label
594
+ self._sync_picker_from_selected()
595
+ self.set_focus(self.query_one("#meta-name", Input))
596
+ self.call_after_refresh(self._mark_ready)
597
+
598
+ def _mark_ready(self) -> None:
599
+ self._ready = True
600
+
601
+ def on_click(self, event: Click) -> None:
602
+ if self._dismiss_started:
603
+ return
604
+ if event.widget is self:
605
+ self.action_cancel()
606
+
607
+ @on(_ColorRoleSelected)
608
+ def _on_color_role_selected(self, event: _ColorRoleSelected) -> None:
609
+ self._selected_color_key = event.key
610
+ self._sync_picker_from_selected()
611
+
612
+ @on(_PickerColorChanged)
613
+ def _on_picker_color_changed(self, event: _PickerColorChanged) -> None:
614
+ plane = self.query_one("#color-plane", _ColorPlane)
615
+ strip = self.query_one("#hue-strip", _HueStrip)
616
+ if not event.value:
617
+ plane.set_hsv(strip.hue, plane.saturation, plane.value)
618
+ value = _hsv_to_hex(strip.hue, plane.saturation, plane.value)
619
+ else:
620
+ value = event.value
621
+ hsv = _hex_to_hsv(value)
622
+ if hsv is not None:
623
+ strip.set_hue(hsv[0])
624
+ plane.set_hsv(*hsv)
625
+ self._picker_hsv = (plane.hue, plane.saturation, plane.value)
626
+ self._on_color_changed(_DesignerColorChanged(self._selected_color_key, value))
627
+
628
+ @on(_DesignerColorChanged)
629
+ def _on_color_changed(self, event: _DesignerColorChanged) -> None:
630
+ self._values[event.key] = event.value
631
+ try:
632
+ self.query_one("#color-roles", _ColorRoleList).update_value(event.key, event.value)
633
+ if event.key == self._selected_color_key:
634
+ self.query_one("#color-readout", _ColorReadout).set_color(
635
+ _COLOR_LABELS[event.key], event.value
636
+ )
637
+ except Exception: # noqa: BLE001
638
+ pass
639
+ if _HEX_RE.match((event.value or "").strip()) or not event.value:
640
+ self._schedule_preview()
641
+
642
+ def _sync_picker_from_selected(self) -> None:
643
+ value = self._values.get(self._selected_color_key, "")
644
+ hsv = _hex_to_hsv(value)
645
+ if hsv is not None:
646
+ self._picker_hsv = hsv
647
+ hue, saturation, brightness = self._picker_hsv
648
+ try:
649
+ self.query_one("#color-plane", _ColorPlane).set_hsv(hue, saturation, brightness)
650
+ self.query_one("#hue-strip", _HueStrip).set_hue(hue)
651
+ self.query_one("#color-readout", _ColorReadout).set_color(
652
+ _COLOR_LABELS[self._selected_color_key], value
653
+ )
654
+ except Exception: # noqa: BLE001
655
+ pass
656
+
657
+ @on(Input.Changed, "#meta-name")
658
+ def _on_name_changed(self, event: Input.Changed) -> None:
659
+ self._name = event.value.strip()
660
+
661
+ @on(Input.Changed, "#meta-label")
662
+ def _on_label_changed(self, event: Input.Changed) -> None:
663
+ self._label = event.value.strip()
664
+
665
+ @on(Select.Changed, "#meta-extends")
666
+ def _on_extends_changed(self, event: Select.Changed) -> None:
667
+ if isinstance(event.value, str):
668
+ self._extends = event.value
669
+ self._schedule_preview()
670
+
671
+ @on(Select.Changed, "#meta-prompt-border")
672
+ def _on_border_changed(self, event: Select.Changed) -> None:
673
+ if isinstance(event.value, str) and event.value in _BORDER_STYLES:
674
+ self._prompt_border = event.value
675
+ self._schedule_preview()
676
+
677
+ @on(Click, "#inherit-color")
678
+ def _on_inherit_clicked(self, event: Click) -> None:
679
+ event.stop()
680
+ self.action_inherit_color()
681
+
682
+ def action_inherit_color(self) -> None:
683
+ self._on_color_changed(_DesignerColorChanged(self._selected_color_key, ""))
684
+ self._sync_picker_from_selected()
685
+
686
+ def action_cancel(self) -> None:
687
+ if self._dismiss_started:
688
+ return
689
+ self._dismiss_started = True
690
+ self._cancel_preview_timer()
691
+ self._restore_original()
692
+ self.dismiss(None)
693
+ self._defer_host_theme_apply(self._original, persist=False, announce=False)
694
+
695
+ def action_save(self) -> None:
696
+ if self._dismiss_started:
697
+ return
698
+ name = self._name or "custom"
699
+ try:
700
+ self._save_theme(name)
701
+ except Exception as exc: # noqa: BLE001
702
+ try:
703
+ self.query_one("#designer-window").border_subtitle = f"save failed: {exc}"
704
+ except Exception: # noqa: BLE001
705
+ pass
706
+ return
707
+ self._dismiss_started = True
708
+ self._cancel_preview_timer()
709
+ self.dismiss(("theme", name))
710
+ self._defer_host_theme_apply(name, persist=True, announce=True)
711
+
712
+ def _build_theme_dict(self) -> dict[str, Any]:
713
+ data: dict[str, Any] = {
714
+ "extends": self._extends or "cursor-dark",
715
+ "label": self._label or self._name or "Custom",
716
+ "prompt_border": self._prompt_border or "tall",
717
+ }
718
+ for key, _ in _COLOR_FIELDS:
719
+ value = (self._values.get(key) or "").strip()
720
+ if value:
721
+ data[key] = value
722
+ if self._code_theme:
723
+ data["code_theme"] = self._code_theme
724
+ for field, value in (
725
+ ("rich_user", self._rich_user),
726
+ ("rich_info_border", self._rich_info_border),
727
+ ("rich_ok_border", self._rich_ok_border),
728
+ ("rich_activity", self._rich_activity),
729
+ ):
730
+ if value:
731
+ data[field] = value
732
+ return data
733
+
734
+ def _preview_signature(self, data: dict[str, Any]) -> str:
735
+ items = sorted((str(key), str(value)) for key, value in data.items())
736
+ return repr(items)
737
+
738
+ def _cancel_preview_timer(self) -> None:
739
+ timer = getattr(self, "_preview_timer", None)
740
+ self._preview_timer = None
741
+ self._preview_seq = int(getattr(self, "_preview_seq", 0)) + 1
742
+ if timer is not None:
743
+ try:
744
+ timer.stop()
745
+ except Exception: # noqa: BLE001
746
+ pass
747
+
748
+ def _schedule_preview(self) -> None:
749
+ if getattr(self, "_dismiss_started", False) or not getattr(self, "_ready", False):
750
+ return
751
+ self._preview_seq = int(getattr(self, "_preview_seq", 0)) + 1
752
+ sequence = self._preview_seq
753
+ timer = getattr(self, "_preview_timer", None)
754
+ if timer is not None:
755
+ try:
756
+ timer.stop()
757
+ except Exception: # noqa: BLE001
758
+ pass
759
+ self._preview_timer = None
760
+
761
+ def fire() -> None:
762
+ self._preview_timer = None
763
+ if getattr(self, "_dismiss_started", False) or sequence != self._preview_seq:
764
+ return
765
+ self._apply_preview()
766
+
767
+ try:
768
+ self._preview_timer = self.set_timer(_PREVIEW_DEBOUNCE_S, fire)
769
+ except Exception: # noqa: BLE001
770
+ if not getattr(self, "_dismiss_started", False) and sequence == self._preview_seq:
771
+ self._apply_preview()
772
+
773
+ def _apply_preview(self) -> None:
774
+ if self._dismiss_started or self._preview_busy:
775
+ return
776
+ self._preview_busy = True
777
+ try:
778
+ from synapse.ui.theme import _custom, set_active_theme
779
+
780
+ data = self._build_theme_dict()
781
+ signature = self._preview_signature(data)
782
+ if signature == self._last_preview_sig:
783
+ return
784
+ theme = _theme_from_dict("__designer_temp__", data, catalog=dict(_custom))
785
+ set_active_theme(theme)
786
+ self._last_preview_sig = signature
787
+ app = self.app
788
+ if hasattr(app, "refresh_css"):
789
+ app.refresh_css(animate=False)
790
+ if hasattr(app, "_repaint_themed_widgets"):
791
+ app._repaint_themed_widgets() # type: ignore[attr-defined]
792
+ except Exception: # noqa: BLE001
793
+ pass
794
+ finally:
795
+ self._preview_busy = False
796
+
797
+ def _restore_original(self) -> None:
798
+ try:
799
+ set_theme(
800
+ self._original,
801
+ workspace=self._project_root,
802
+ persist=False,
803
+ reload=False,
804
+ )
805
+ except Exception: # noqa: BLE001
806
+ pass
807
+
808
+ def _defer_host_theme_apply(
809
+ self,
810
+ name: str,
811
+ *,
812
+ persist: bool,
813
+ announce: bool,
814
+ ) -> None:
815
+ """Refresh the host only after ``dismiss`` has queued screen removal."""
816
+ try:
817
+ app = self.app
818
+ except Exception: # noqa: BLE001
819
+ return
820
+
821
+ def apply_to_host() -> None:
822
+ try:
823
+ if hasattr(app, "apply_theme"):
824
+ app.apply_theme(name, persist=persist, announce=announce)
825
+ elif hasattr(app, "refresh_css"):
826
+ app.refresh_css(animate=False)
827
+ except Exception as exc: # noqa: BLE001
828
+ if hasattr(app, "append_event"):
829
+ app.append_event(f"theme failed: {exc}", "yellow")
830
+
831
+ app.call_later(apply_to_host)
832
+
833
+ def _save_theme(self, name: str) -> None:
834
+ key = (name or "").strip()
835
+ if not key:
836
+ raise ValueError("theme name is empty")
837
+ if not re.fullmatch(r"[A-Za-z0-9._-]+", key):
838
+ raise ValueError("theme name must be [A-Za-z0-9._-]+")
839
+
840
+ data = self._build_theme_dict()
841
+ path = user_config_dir() / THEMES_FILENAME
842
+ existing: dict[str, Any] = {}
843
+ if path.is_file():
844
+ try:
845
+ loaded = json.loads(path.read_text(encoding="utf-8"))
846
+ if isinstance(loaded, dict):
847
+ existing = loaded
848
+ except Exception: # noqa: BLE001
849
+ existing = {}
850
+
851
+ themes = existing.get("themes", {})
852
+ if not isinstance(themes, dict):
853
+ themes = {}
854
+ themes[key] = data
855
+ existing["themes"] = themes
856
+ path.parent.mkdir(parents=True, exist_ok=True)
857
+ path.write_text(
858
+ json.dumps(existing, indent=2, ensure_ascii=False) + "\n",
859
+ encoding="utf-8",
860
+ )
861
+
862
+ reload_theme_catalog(self._project_root)
863
+ set_theme(key, workspace=self._project_root, persist=True, reload=False)