soothe-cli 0.1.0__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 (107) hide show
  1. soothe_cli/__init__.py +5 -0
  2. soothe_cli/cli/__init__.py +1 -0
  3. soothe_cli/cli/commands/__init__.py +1 -0
  4. soothe_cli/cli/commands/autopilot_cmd.py +410 -0
  5. soothe_cli/cli/commands/config_cmd.py +277 -0
  6. soothe_cli/cli/commands/run_cmd.py +87 -0
  7. soothe_cli/cli/commands/status_cmd.py +121 -0
  8. soothe_cli/cli/commands/subagent_names.py +17 -0
  9. soothe_cli/cli/commands/thread_cmd.py +657 -0
  10. soothe_cli/cli/execution/__init__.py +6 -0
  11. soothe_cli/cli/execution/daemon.py +194 -0
  12. soothe_cli/cli/execution/headless.py +99 -0
  13. soothe_cli/cli/execution/launcher.py +31 -0
  14. soothe_cli/cli/main.py +509 -0
  15. soothe_cli/cli/renderer.py +444 -0
  16. soothe_cli/cli/stream/__init__.py +17 -0
  17. soothe_cli/cli/stream/context.py +138 -0
  18. soothe_cli/cli/stream/display_line.py +83 -0
  19. soothe_cli/cli/stream/formatter.py +412 -0
  20. soothe_cli/cli/stream/pipeline.py +521 -0
  21. soothe_cli/cli/utils.py +46 -0
  22. soothe_cli/config/__init__.py +5 -0
  23. soothe_cli/config/cli_config.py +155 -0
  24. soothe_cli/plan/__init__.py +5 -0
  25. soothe_cli/plan/rich_tree.py +54 -0
  26. soothe_cli/shared/__init__.py +107 -0
  27. soothe_cli/shared/command_router.py +246 -0
  28. soothe_cli/shared/config_loader.py +68 -0
  29. soothe_cli/shared/display_policy.py +413 -0
  30. soothe_cli/shared/essential_events.py +68 -0
  31. soothe_cli/shared/event_processor.py +823 -0
  32. soothe_cli/shared/message_processing.py +393 -0
  33. soothe_cli/shared/presentation_engine.py +173 -0
  34. soothe_cli/shared/processor_state.py +80 -0
  35. soothe_cli/shared/renderer_protocol.py +158 -0
  36. soothe_cli/shared/rendering.py +43 -0
  37. soothe_cli/shared/slash_commands.py +354 -0
  38. soothe_cli/shared/subagent_routing.py +63 -0
  39. soothe_cli/shared/suppression_state.py +188 -0
  40. soothe_cli/shared/tool_formatters/__init__.py +27 -0
  41. soothe_cli/shared/tool_formatters/base.py +109 -0
  42. soothe_cli/shared/tool_formatters/execution.py +297 -0
  43. soothe_cli/shared/tool_formatters/fallback.py +128 -0
  44. soothe_cli/shared/tool_formatters/file_ops.py +299 -0
  45. soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
  46. soothe_cli/shared/tool_formatters/media.py +291 -0
  47. soothe_cli/shared/tool_formatters/structured.py +202 -0
  48. soothe_cli/shared/tool_formatters/web.py +143 -0
  49. soothe_cli/shared/tool_output_formatter.py +227 -0
  50. soothe_cli/shared/tui_trace_log.py +40 -0
  51. soothe_cli/tui/__init__.py +5 -0
  52. soothe_cli/tui/_ask_user_types.py +50 -0
  53. soothe_cli/tui/_cli_context.py +27 -0
  54. soothe_cli/tui/_env_vars.py +56 -0
  55. soothe_cli/tui/_session_stats.py +114 -0
  56. soothe_cli/tui/_version.py +21 -0
  57. soothe_cli/tui/app.py +4992 -0
  58. soothe_cli/tui/app.tcss +302 -0
  59. soothe_cli/tui/command_registry.py +310 -0
  60. soothe_cli/tui/config.py +2381 -0
  61. soothe_cli/tui/daemon_session.py +233 -0
  62. soothe_cli/tui/file_ops.py +409 -0
  63. soothe_cli/tui/formatting.py +28 -0
  64. soothe_cli/tui/hooks.py +23 -0
  65. soothe_cli/tui/input.py +782 -0
  66. soothe_cli/tui/media_utils.py +471 -0
  67. soothe_cli/tui/model_config.py +518 -0
  68. soothe_cli/tui/output.py +69 -0
  69. soothe_cli/tui/project_utils.py +188 -0
  70. soothe_cli/tui/sessions.py +1248 -0
  71. soothe_cli/tui/skills/__init__.py +5 -0
  72. soothe_cli/tui/skills/invocation.py +74 -0
  73. soothe_cli/tui/skills/load.py +93 -0
  74. soothe_cli/tui/textual_adapter.py +1430 -0
  75. soothe_cli/tui/theme.py +838 -0
  76. soothe_cli/tui/tool_display.py +297 -0
  77. soothe_cli/tui/unicode_security.py +502 -0
  78. soothe_cli/tui/update_check.py +447 -0
  79. soothe_cli/tui/widgets/__init__.py +9 -0
  80. soothe_cli/tui/widgets/_links.py +63 -0
  81. soothe_cli/tui/widgets/approval.py +430 -0
  82. soothe_cli/tui/widgets/ask_user.py +392 -0
  83. soothe_cli/tui/widgets/autocomplete.py +666 -0
  84. soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
  85. soothe_cli/tui/widgets/autopilot_screen.py +64 -0
  86. soothe_cli/tui/widgets/chat_input.py +1834 -0
  87. soothe_cli/tui/widgets/clipboard.py +128 -0
  88. soothe_cli/tui/widgets/diff.py +240 -0
  89. soothe_cli/tui/widgets/editor.py +140 -0
  90. soothe_cli/tui/widgets/history.py +221 -0
  91. soothe_cli/tui/widgets/loading.py +194 -0
  92. soothe_cli/tui/widgets/mcp_viewer.py +352 -0
  93. soothe_cli/tui/widgets/message_store.py +693 -0
  94. soothe_cli/tui/widgets/messages.py +1720 -0
  95. soothe_cli/tui/widgets/model_selector.py +988 -0
  96. soothe_cli/tui/widgets/notification_settings.py +155 -0
  97. soothe_cli/tui/widgets/status.py +403 -0
  98. soothe_cli/tui/widgets/theme_selector.py +158 -0
  99. soothe_cli/tui/widgets/thread_selector.py +1865 -0
  100. soothe_cli/tui/widgets/tool_renderers.py +148 -0
  101. soothe_cli/tui/widgets/tool_widgets.py +254 -0
  102. soothe_cli/tui/widgets/tools.py +165 -0
  103. soothe_cli/tui/widgets/welcome.py +330 -0
  104. soothe_cli-0.1.0.dist-info/METADATA +100 -0
  105. soothe_cli-0.1.0.dist-info/RECORD +107 -0
  106. soothe_cli-0.1.0.dist-info/WHEEL +4 -0
  107. soothe_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,838 @@
1
+ """LangChain brand colors and semantic constants for the CLI.
2
+
3
+ Single source of truth for color values used in Python code (Rich markup,
4
+ `Content.styled`, `Content.from_markup`). CSS-side styling should reference
5
+ Textual CSS variables: built-in variables
6
+ (`$primary`, `$background`, `$text-muted`, `$error-muted`, etc.) are set via
7
+ `register_theme()` in `SootheApp.__init__`, while the few app-specific
8
+ variables (`$mode-bash`, `$mode-command`, `$skill`, `$skill-hover`, `$tool`,
9
+ `$tool-hover`) are backed by these constants via `App.get_theme_variable_defaults()`.
10
+
11
+ Code that needs custom CSS variable values should call
12
+ `get_css_variable_defaults(dark=...)`. For the full semantic color palette, look
13
+ up the `ThemeColors` instance via `ThemeEntry.REGISTRY`.
14
+
15
+ Users can define custom themes in `~/SOOTHE_HOME/config/config.yml` under
16
+ `[themes.<name>]` sections. Each new theme section must include `label` (str);
17
+ `dark` (bool) defaults to `False` if omitted (set to `True` for dark themes).
18
+ Color fields are optional and fall back to the built-in dark/light palette based
19
+ on the `dark` flag. Sections whose name matches a built-in theme override its
20
+ colors without replacing it. See `_load_user_themes()` for details.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ import re
27
+ from dataclasses import dataclass, fields
28
+ from pathlib import Path
29
+ from types import MappingProxyType
30
+ from typing import TYPE_CHECKING, Any, ClassVar
31
+
32
+ from soothe_sdk import SOOTHE_HOME
33
+
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Mapping
36
+
37
+ from textual.app import App
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Brand palette — dark (originally tokyonight-inspired, LangChain blue primary)
43
+ # ---------------------------------------------------------------------------
44
+ LC_DARK = "#11121D"
45
+ """Background — visible blue tint, distinguishable from pure black."""
46
+
47
+ LC_CARD = "#1A1B2E"
48
+ """Surface / card — clearly elevated above background."""
49
+
50
+ LC_BORDER_DK = "#25283B"
51
+ """Borders on dark backgrounds."""
52
+
53
+ LC_BORDER_LT = "#3A3E57"
54
+ """Borders on lighter / hovered backgrounds."""
55
+
56
+ LC_BODY = "#C0CAF5"
57
+ """Body text — high contrast on dark backgrounds."""
58
+
59
+ LC_BLUE = "#7AA2F7"
60
+ """Primary accent blue."""
61
+
62
+ LC_PURPLE = "#BB9AF7"
63
+ """Secondary accent / badges / labels."""
64
+
65
+ LC_GREEN = "#9ECE6A"
66
+ """Success / positive indicator."""
67
+
68
+ LC_AMBER = "#EB8B46"
69
+ """Warning / caution indicator."""
70
+
71
+ LC_PINK = "#F7768E"
72
+ """Error / destructive actions."""
73
+
74
+ LC_MUTED = "#545C7E"
75
+ """Muted / secondary text."""
76
+
77
+ LC_GREEN_BG = "#1C2A38"
78
+ """Subtle green-tinted background for diff additions."""
79
+
80
+ LC_PINK_BG = "#2A1F32"
81
+ """Subtle pink-tinted background for diff removals / errors."""
82
+
83
+ LC_PANEL = "#25283B"
84
+ """Panel — differentiated section background (above surface)."""
85
+
86
+ LC_SKILL = "#A78BFA"
87
+ """Skill invocation accent — border and header text."""
88
+
89
+ LC_SKILL_HOVER = "#C4B5FD"
90
+ """Skill invocation hover — lighter variant for interactive feedback."""
91
+
92
+ LC_TOOL = LC_AMBER
93
+ """Tool call accent — border and header text."""
94
+
95
+ LC_TOOL_HOVER = "#FFCB91"
96
+ """Tool call hover — lighter variant for interactive feedback."""
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Brand palette — light
101
+ # ---------------------------------------------------------------------------
102
+ LC_LIGHT_BG = "#F5F5F7"
103
+ """Background — warm neutral white."""
104
+
105
+ LC_LIGHT_SURFACE = "#EAEAEE"
106
+ """Surface / card — slightly darker than background."""
107
+
108
+ LC_LIGHT_BORDER = "#C8CAD0"
109
+ """Borders on light backgrounds."""
110
+
111
+ LC_LIGHT_BORDER_HVR = "#A0A4B0"
112
+ """Borders on hovered / focused surfaces."""
113
+
114
+ LC_LIGHT_BODY = "#24283B"
115
+ """Body text — high contrast on light backgrounds."""
116
+
117
+ LC_LIGHT_BLUE = "#2E5EAA"
118
+ """Primary accent blue (darkened for light bg contrast)."""
119
+
120
+ LC_LIGHT_PURPLE = "#7C3AED"
121
+ """Secondary accent (darkened for light bg contrast)."""
122
+
123
+ LC_LIGHT_GREEN = "#3A7D0A"
124
+ """Success / positive (darkened for light bg contrast)."""
125
+
126
+ LC_LIGHT_AMBER = "#B45309"
127
+ """Warning / caution (darkened for light bg contrast)."""
128
+
129
+ LC_LIGHT_PINK = "#BE185D"
130
+ """Error / destructive (darkened for light bg contrast)."""
131
+
132
+ LC_LIGHT_MUTED = "#6B7280"
133
+ """Muted / secondary text on light backgrounds."""
134
+
135
+ LC_LIGHT_GREEN_BG = "#DCFCE7"
136
+ """Subtle green-tinted background for diff additions."""
137
+
138
+ LC_LIGHT_PINK_BG = "#FEE2E2"
139
+ """Subtle pink-tinted background for diff removals / errors."""
140
+
141
+ LC_LIGHT_PANEL = "#E0E1E6"
142
+ """Panel for light theme — differentiated section background."""
143
+
144
+ LC_LIGHT_SKILL = "#7C3AED"
145
+ """Skill invocation accent (darkened for light bg contrast)."""
146
+
147
+ LC_LIGHT_SKILL_HOVER = "#6D28D9"
148
+ """Skill invocation hover (darkened for light bg contrast)."""
149
+
150
+ LC_LIGHT_TOOL = LC_LIGHT_AMBER
151
+ """Tool call accent (darkened for light bg contrast)."""
152
+
153
+ LC_LIGHT_TOOL_HOVER = "#78350F"
154
+ """Tool call hover (darkened for light bg contrast)."""
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Semantic constants (ANSI color names for Rich console output)
159
+ #
160
+ # These are ANSI color names resolved by the user's terminal palette, so they
161
+ # adapt to both dark and light terminal backgrounds automatically. They are
162
+ # used in Rich's `Console.print()` (non-interactive output, help screens,
163
+ # `non_interactive.py`, `main.py`).
164
+ #
165
+ # Textual widget code should NOT use these. Instead, call
166
+ # `get_theme_colors(self.app)` to obtain the active theme's `ThemeColors`
167
+ # (hex values), or reference CSS variables (`$primary`, `$muted`, etc.).
168
+ # ---------------------------------------------------------------------------
169
+ PRIMARY = "blue"
170
+ """Default accent for headings, borders, links, and active elements."""
171
+
172
+ PRIMARY_DEV = "bright_red"
173
+ """Accent used when running from an editable (dev) install."""
174
+
175
+ SUCCESS = "green"
176
+ """Positive outcomes — tool success, approved actions."""
177
+
178
+ WARNING = "yellow"
179
+ """Caution and notice states — auto-approve off, pending tool calls, notices."""
180
+
181
+ MUTED = "bright_black"
182
+ """De-emphasized text — timestamps, secondary labels."""
183
+
184
+ MODE_BASH = "red"
185
+ """Shell mode indicator — borders, prompts, and message prefixes."""
186
+
187
+ MODE_COMMAND = "magenta"
188
+ """Command mode indicator — borders, prompts, and message prefixes."""
189
+
190
+ # Diff colors
191
+ DIFF_ADD_FG = "green"
192
+ """Added-line foreground in inline diffs."""
193
+
194
+ DIFF_ADD_BG = "green"
195
+ """Added-line background in inline diffs."""
196
+
197
+ DIFF_REMOVE_FG = "red"
198
+ """Removed-line foreground in inline diffs."""
199
+
200
+ DIFF_REMOVE_BG = "red"
201
+ """Removed-line background in inline diffs."""
202
+
203
+ DIFF_CONTEXT = "bright_black"
204
+ """Unchanged context lines in inline diffs."""
205
+
206
+ # Tool call widget
207
+ TOOL_BORDER = "bright_black"
208
+ """Tool call card border."""
209
+
210
+ TOOL_HEADER = "yellow"
211
+ """Tool call headers, slash-command tokens, and approval-menu commands."""
212
+
213
+ # File listing colors
214
+ FILE_PYTHON = "blue"
215
+ """Python files in tool-call file listings."""
216
+
217
+ FILE_CONFIG = "yellow"
218
+ """Config / data files in tool-call file listings."""
219
+
220
+ FILE_DIR = "green"
221
+ """Directories in tool-call file listings."""
222
+
223
+ SPINNER = "blue"
224
+ """Loading spinner color."""
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # Theme variant dataclass
229
+ # ---------------------------------------------------------------------------
230
+
231
+
232
+ _HEX_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
233
+ """Matches a 7-character hex color string like `#7AA2F7`.
234
+
235
+ Textual's `Color.parse` could also validate, but importing it here would pull
236
+ Textual into `theme.py` which is otherwise pure Python with zero framework deps.
237
+ """
238
+
239
+
240
+ @dataclass(frozen=True, slots=True)
241
+ class ThemeColors:
242
+ """Complete set of semantic colors for one theme variant.
243
+
244
+ Every field must be a 7-character hex color string (e.g., `'#7AA2F7'`).
245
+ """
246
+
247
+ primary: str
248
+ """Accent for headings, borders, links, and active elements."""
249
+
250
+ secondary: str
251
+ """Secondary accent for badges, labels, and decorative highlights."""
252
+
253
+ accent: str
254
+ """Attention-drawing contrast accent, distinct from primary/secondary."""
255
+
256
+ panel: str
257
+ """Differentiated section background (above surface)."""
258
+
259
+ success: str
260
+ """Positive outcomes — tool success, approved actions."""
261
+
262
+ warning: str
263
+ """Caution and notice states — pending tool calls, notices."""
264
+
265
+ error: str
266
+ """Error and destructive-action indicator."""
267
+
268
+ muted: str
269
+ """De-emphasized text — timestamps, secondary labels."""
270
+
271
+ mode_bash: str
272
+ """Shell mode indicator — borders, prompts, and message prefixes."""
273
+
274
+ mode_command: str
275
+ """Command mode indicator — borders, prompts, and message prefixes."""
276
+
277
+ skill: str
278
+ """Skill invocation accent — border and header text."""
279
+
280
+ skill_hover: str
281
+ """Skill invocation hover — contrasting variant for interactive feedback."""
282
+
283
+ tool: str
284
+ """Tool call accent — border and header text."""
285
+
286
+ tool_hover: str
287
+ """Tool call hover — contrasting variant for interactive feedback."""
288
+
289
+ foreground: str
290
+ """Primary body text."""
291
+
292
+ background: str
293
+ """Base application background."""
294
+
295
+ surface: str
296
+ """Elevated card / panel background."""
297
+
298
+ def __post_init__(self) -> None:
299
+ """Validate that every field is a valid hex color.
300
+
301
+ Raises:
302
+ ValueError: If any field is not a 7-character hex color string.
303
+ """
304
+ for f in fields(self):
305
+ val = getattr(self, f.name)
306
+ if not _HEX_RE.match(val):
307
+ msg = f"ThemeColors.{f.name} must be a 7-char hex color (#RRGGBB), got {val!r}"
308
+ raise ValueError(msg)
309
+
310
+ @classmethod
311
+ def merged(cls, base: ThemeColors, overrides: dict[str, str]) -> ThemeColors:
312
+ """Create a new `ThemeColors` by overlaying overrides onto a base.
313
+
314
+ Fields present in `overrides` replace the corresponding base value;
315
+ missing fields inherit from `base`. This lets users specify only the
316
+ colors they want to customize.
317
+
318
+ Args:
319
+ base: Fallback color set for any field not in `overrides`.
320
+ overrides: Field-name to hex-color mapping. Unknown keys are
321
+ silently ignored.
322
+
323
+ Returns:
324
+ New `ThemeColors` with merged values.
325
+ """
326
+ valid_names = {f.name for f in fields(cls)}
327
+ kwargs = {f.name: getattr(base, f.name) for f in fields(cls)}
328
+ kwargs.update({k: v for k, v in overrides.items() if k in valid_names})
329
+ return cls(**kwargs)
330
+
331
+
332
+ # ---------------------------------------------------------------------------
333
+ # Built-in theme color sets
334
+ # ---------------------------------------------------------------------------
335
+
336
+ DARK_COLORS = ThemeColors(
337
+ primary=LC_BLUE,
338
+ secondary=LC_PURPLE,
339
+ accent=LC_GREEN,
340
+ panel=LC_PANEL,
341
+ success=LC_GREEN,
342
+ warning=LC_AMBER,
343
+ error=LC_PINK,
344
+ muted=LC_MUTED,
345
+ mode_bash=LC_PINK,
346
+ mode_command=LC_PURPLE,
347
+ skill=LC_SKILL,
348
+ skill_hover=LC_SKILL_HOVER,
349
+ tool=LC_TOOL,
350
+ tool_hover=LC_TOOL_HOVER,
351
+ foreground=LC_BODY,
352
+ background=LC_DARK,
353
+ surface=LC_CARD,
354
+ )
355
+ """Color set for the dark LangChain theme."""
356
+
357
+ LIGHT_COLORS = ThemeColors(
358
+ primary=LC_LIGHT_BLUE,
359
+ secondary=LC_LIGHT_PURPLE,
360
+ accent=LC_LIGHT_GREEN,
361
+ panel=LC_LIGHT_PANEL,
362
+ success=LC_LIGHT_GREEN,
363
+ warning=LC_LIGHT_AMBER,
364
+ error=LC_LIGHT_PINK,
365
+ muted=LC_LIGHT_MUTED,
366
+ mode_bash=LC_LIGHT_PINK,
367
+ mode_command=LC_LIGHT_PURPLE,
368
+ skill=LC_LIGHT_SKILL,
369
+ skill_hover=LC_LIGHT_SKILL_HOVER,
370
+ tool=LC_LIGHT_TOOL,
371
+ tool_hover=LC_LIGHT_TOOL_HOVER,
372
+ foreground=LC_LIGHT_BODY,
373
+ background=LC_LIGHT_BG,
374
+ surface=LC_LIGHT_SURFACE,
375
+ )
376
+ """Color set for the light LangChain theme."""
377
+
378
+
379
+ # ---------------------------------------------------------------------------
380
+ # Available themes (name → display label, dark flag, colors)
381
+ # ---------------------------------------------------------------------------
382
+
383
+
384
+ @dataclass(frozen=True, slots=True)
385
+ class ThemeEntry:
386
+ """Metadata for a registered theme."""
387
+
388
+ label: str
389
+ """Human-readable label shown in the theme picker."""
390
+
391
+ dark: bool
392
+ """Whether this is a dark theme variant."""
393
+
394
+ colors: ThemeColors
395
+ """Resolved color set."""
396
+
397
+ custom: bool = True
398
+ """Whether this theme must be registered with Textual via `register_theme()`.
399
+
400
+ `True` for LangChain-branded themes and user-defined themes.
401
+ `False` for Textual built-in themes that Textual already knows about.
402
+ """
403
+
404
+ REGISTRY: ClassVar[Mapping[str, ThemeEntry]]
405
+ """All registered theme entries, keyed by Textual theme name.
406
+
407
+ Read-only after module load (`MappingProxyType`).
408
+ """
409
+
410
+ def __post_init__(self) -> None:
411
+ """Validate that the label is a non-empty string.
412
+
413
+ Raises:
414
+ ValueError: If `label` is empty or whitespace-only.
415
+ """
416
+ if not self.label.strip():
417
+ msg = "ThemeEntry.label must be a non-empty string"
418
+ raise ValueError(msg)
419
+
420
+
421
+ def _builtin_themes() -> dict[str, ThemeEntry]:
422
+ """Return the built-in theme entries as a mutable dict.
423
+
424
+ Returns:
425
+ Dict of built-in theme names to `ThemeEntry` instances.
426
+ """
427
+ r: dict[str, ThemeEntry] = {}
428
+ r["langchain"] = ThemeEntry(
429
+ label="LangChain Dark",
430
+ dark=True,
431
+ colors=DARK_COLORS,
432
+ )
433
+ r["langchain-light"] = ThemeEntry(
434
+ label="LangChain Light",
435
+ dark=False,
436
+ colors=LIGHT_COLORS,
437
+ )
438
+ # Textual built-in themes — not registered via register_theme() (Textual's
439
+ # own $primary, $background, etc. apply). The `colors` field provides
440
+ # fallback values for app-specific CSS vars ($mode-bash, $mode-command) and
441
+ # Python-side styling. For standard properties (primary, secondary, etc.),
442
+ # get_theme_colors() dynamically resolves from the actual Textual theme at
443
+ # runtime so the Python and CSS color systems stay in sync.
444
+
445
+ def _bi(label: str, *, is_dark: bool) -> ThemeEntry:
446
+ return ThemeEntry(
447
+ label=label,
448
+ dark=is_dark,
449
+ colors=DARK_COLORS if is_dark else LIGHT_COLORS,
450
+ custom=False,
451
+ )
452
+
453
+ r["textual-dark"] = _bi("Textual Dark", is_dark=True)
454
+ r["textual-light"] = _bi("Textual Light", is_dark=False)
455
+ r["textual-ansi"] = _bi("Terminal (ANSI)", is_dark=False)
456
+ # Popular community themes (all ship with Textual >= 8.0)
457
+ r["atom-one-dark"] = _bi("Atom One Dark", is_dark=True)
458
+ r["atom-one-light"] = _bi("Atom One Light", is_dark=False)
459
+ r["catppuccin-frappe"] = _bi("Catppuccin Frappé", is_dark=True)
460
+ r["catppuccin-latte"] = _bi("Catppuccin Latte", is_dark=False)
461
+ r["catppuccin-macchiato"] = _bi("Catppuccin Macchiato", is_dark=True)
462
+ r["catppuccin-mocha"] = _bi("Catppuccin Mocha", is_dark=True)
463
+ r["dracula"] = _bi("Dracula", is_dark=True)
464
+ r["flexoki"] = _bi("Flexoki", is_dark=True)
465
+ r["gruvbox"] = _bi("Gruvbox", is_dark=True)
466
+ r["monokai"] = _bi("Monokai", is_dark=True)
467
+ r["nord"] = _bi("Nord", is_dark=True)
468
+ r["rose-pine"] = _bi("Rosé Pine", is_dark=True)
469
+ r["rose-pine-dawn"] = _bi("Rosé Pine Dawn", is_dark=False)
470
+ r["rose-pine-moon"] = _bi("Rosé Pine Moon", is_dark=True)
471
+ r["solarized-dark"] = _bi("Solarized Dark", is_dark=True)
472
+ r["solarized-light"] = _bi("Solarized Light", is_dark=False)
473
+ r["tokyo-night"] = _bi("Tokyo Night", is_dark=True)
474
+ return r
475
+
476
+
477
+ _BUILTIN_NAMES: frozenset[str] = frozenset(_builtin_themes())
478
+ """Names of built-in themes.
479
+
480
+ User `[themes.<name>]` sections matching a built-in name override its colors
481
+ rather than creating a new theme. Derived from `_builtin_themes()` to stay in
482
+ sync automatically.
483
+ """
484
+
485
+
486
+ def _load_user_themes(
487
+ builtins: dict[str, ThemeEntry],
488
+ *,
489
+ config_path: Path | None = None,
490
+ ) -> None:
491
+ """Load user-defined themes from `config.yml` into `builtins` (mutated).
492
+
493
+ **New themes** — each `[themes.<name>]` section (where `<name>` is not a
494
+ built-in) must have:
495
+
496
+ - `label` (str) — human-readable name shown in the theme picker.
497
+ - `dark` (bool, optional) — whether this is a dark-mode variant.
498
+
499
+ Defaults to `False` (light).
500
+
501
+ **Built-in overrides** — if `<name>` matches a built-in theme, only color
502
+ fields are read; `label` and `dark` are inherited from the built-in.
503
+
504
+ All `ThemeColors` fields are optional. For new themes, omitted fields
505
+ fall back to the built-in dark or light palette based on the `dark` flag.
506
+
507
+ For built-in overrides, omitted fields retain the existing built-in colors.
508
+
509
+ Invalid themes (bad hex, missing required keys) are logged as warnings
510
+ and skipped — they never crash startup.
511
+
512
+ Example `config.yml` snippet:
513
+
514
+ ```yaml
515
+ # New custom theme
516
+ [themes.my-solarized]
517
+ label = "My Solarized"
518
+ dark = true
519
+ primary = "#268BD2"
520
+ warning = "#B58900"
521
+
522
+ # Override built-in theme colors
523
+ [themes.langchain]
524
+ primary = "#FF5500"
525
+ ```
526
+
527
+ Args:
528
+ builtins: Mutable dict to update (new themes are added, built-in
529
+ overrides replace existing entries).
530
+ config_path: Override for the config file path (testing).
531
+ """
532
+ if config_path is None:
533
+ try:
534
+ config_path = Path(SOOTHE_HOME) / "config" / "config.yml"
535
+ except RuntimeError:
536
+ logger.debug("Cannot determine home directory; skipping user theme loading")
537
+ return
538
+
539
+ import yaml
540
+
541
+ try:
542
+ if not config_path.exists():
543
+ return
544
+
545
+ with config_path.open("rb") as f:
546
+ data = yaml.safe_load(f)
547
+ except (yaml.YAMLError, PermissionError, OSError) as exc:
548
+ logger.warning(
549
+ "Could not read %s for user themes: %s",
550
+ config_path,
551
+ exc,
552
+ )
553
+ return
554
+
555
+ themes_section: Any = data.get("themes")
556
+ if not isinstance(themes_section, dict) or not themes_section:
557
+ return
558
+
559
+ valid_color_names = {f.name for f in fields(ThemeColors)}
560
+ reserved = {"label", "dark"}
561
+
562
+ for name, section in themes_section.items():
563
+ if not isinstance(section, dict):
564
+ logger.warning("Ignoring non-table [themes.%s]", name)
565
+ continue
566
+
567
+ # --- Parse color overrides (shared by built-in overrides & new themes)
568
+ color_overrides: dict[str, str] = {}
569
+ for k, v in section.items():
570
+ if k in reserved:
571
+ continue
572
+ if not isinstance(v, str):
573
+ logger.warning(
574
+ "User theme '%s' field '%s' must be a string, got %s; ignoring",
575
+ name,
576
+ k,
577
+ type(v).__name__,
578
+ )
579
+ continue
580
+ if k in valid_color_names:
581
+ color_overrides[k] = v
582
+ else:
583
+ logger.warning(
584
+ "User theme '%s' has unknown color field '%s'; ignoring",
585
+ name,
586
+ k,
587
+ )
588
+
589
+ # --- Built-in override: merge color tweaks into the existing entry
590
+ if name in _BUILTIN_NAMES:
591
+ existing = builtins.get(name)
592
+ if existing is None:
593
+ logger.warning(
594
+ "Built-in theme '%s' not in builtins dict; skipping override",
595
+ name,
596
+ )
597
+ continue
598
+ if not color_overrides:
599
+ continue
600
+ try:
601
+ colors = ThemeColors.merged(existing.colors, color_overrides)
602
+ except ValueError as exc:
603
+ logger.warning(
604
+ "Built-in theme '%s' color override invalid: %s; skipping",
605
+ name,
606
+ exc,
607
+ )
608
+ continue
609
+ builtins[name] = ThemeEntry(
610
+ label=existing.label,
611
+ dark=existing.dark,
612
+ colors=colors,
613
+ custom=existing.custom,
614
+ )
615
+ continue
616
+
617
+ # --- New custom theme: label required, dark defaults to False (light)
618
+ label = section.get("label")
619
+ if not isinstance(label, str) or not label.strip():
620
+ logger.warning(
621
+ "User theme '%s' missing required 'label' (str); skipping",
622
+ name,
623
+ )
624
+ continue
625
+
626
+ dark = section.get("dark", False)
627
+ if not isinstance(dark, bool):
628
+ logger.warning(
629
+ "User theme '%s': 'dark' must be true or false, got %s (%r); defaulting to light",
630
+ name,
631
+ type(dark).__name__,
632
+ dark,
633
+ )
634
+ dark = False
635
+
636
+ base = DARK_COLORS if dark else LIGHT_COLORS
637
+ try:
638
+ colors = ThemeColors.merged(base, color_overrides)
639
+ except ValueError as exc:
640
+ logger.warning(
641
+ "User theme '%s' has invalid colors: %s; skipping",
642
+ name,
643
+ exc,
644
+ )
645
+ continue
646
+
647
+ builtins[name] = ThemeEntry(
648
+ label=label,
649
+ dark=dark,
650
+ colors=colors,
651
+ custom=True,
652
+ )
653
+
654
+
655
+ def _build_registry(*, config_path: Path | None = None) -> MappingProxyType[str, ThemeEntry]:
656
+ """Build and freeze the theme registry (built-in + user themes).
657
+
658
+ Args:
659
+ config_path: Override for the config file path (testing).
660
+
661
+ Returns:
662
+ Read-only mapping of theme names to `ThemeEntry` instances.
663
+ """
664
+ r = _builtin_themes()
665
+ _load_user_themes(r, config_path=config_path)
666
+ return MappingProxyType(r)
667
+
668
+
669
+ ThemeEntry.REGISTRY = _build_registry()
670
+ """Read-only mapping of Textual theme names to `ThemeEntry` instances.
671
+
672
+ Built via `_build_registry()` so the mutable staging dict is scoped to a
673
+ function call and cannot be mutated after freeze. The `ClassVar` declaration on
674
+ `ThemeEntry` provides the type; this assignment supplies the value.
675
+ """
676
+
677
+ DEFAULT_THEME = "langchain"
678
+ """Theme name used when no preference is saved."""
679
+
680
+
681
+ def reload_registry() -> MappingProxyType[str, ThemeEntry]:
682
+ """Rebuild the theme registry from disk and update `ThemeEntry.REGISTRY`.
683
+
684
+ Re-reads `~/SOOTHE_HOME/config/config.yml` for user-defined themes so that
685
+ `/reload` can pick up config changes without restarting the app.
686
+
687
+ Returns:
688
+ The new frozen registry.
689
+ """
690
+ ThemeEntry.REGISTRY = _build_registry()
691
+ return ThemeEntry.REGISTRY
692
+
693
+
694
+ def get_css_variable_defaults(
695
+ *, dark: bool = True, colors: ThemeColors | None = None
696
+ ) -> dict[str, str]:
697
+ """Return custom CSS variable defaults for the given mode.
698
+
699
+ Most styling is handled by Textual's built-in CSS variables (`$primary`,
700
+ `$text-muted`, `$error-muted`, etc.). This function only returns
701
+ app-specific semantic variables that have no Textual equivalent.
702
+
703
+ Args:
704
+ dark: Selects `DARK_COLORS` or `LIGHT_COLORS` when `colors` is None.
705
+ colors: Explicit color set to use. Takes precedence over `dark`.
706
+
707
+ Returns:
708
+ Dict of CSS variable names to hex color values.
709
+ """
710
+ c = colors if colors is not None else (DARK_COLORS if dark else LIGHT_COLORS)
711
+ return {
712
+ "mode-bash": c.mode_bash,
713
+ "mode-command": c.mode_command,
714
+ "skill": c.skill,
715
+ "skill-hover": c.skill_hover,
716
+ "tool": c.tool,
717
+ "tool-hover": c.tool_hover,
718
+ }
719
+
720
+
721
+ def _resolve_app(widget_or_app: object) -> object:
722
+ """Resolve a widget or App to the App instance.
723
+
724
+ Args:
725
+ widget_or_app: Textual `App` or a mounted widget.
726
+
727
+ Returns:
728
+ The resolved App instance.
729
+ """
730
+ return (
731
+ widget_or_app.app # type: ignore[attr-defined]
732
+ if hasattr(type(widget_or_app), "app")
733
+ else widget_or_app
734
+ )
735
+
736
+
737
+ def _colors_from_textual_theme(app: object) -> ThemeColors:
738
+ """Construct `ThemeColors` from the app's active Textual theme.
739
+
740
+ Reads standard properties (primary, secondary, etc.) from the resolved
741
+ theme so Python-side styling matches CSS. `muted` falls back to the
742
+ dark/light base unconditionally (no Textual equivalent).
743
+ `mode_bash` is derived from the theme's `error` color, and `mode_command`
744
+ from `secondary`, falling back to the base palette when non-hex.
745
+
746
+ Non-hex values (e.g. `ansi_blue` in the ANSI theme) are detected and fall
747
+ back to the base palette automatically.
748
+
749
+ Args:
750
+ app: The Textual App instance.
751
+
752
+ Returns:
753
+ `ThemeColors` derived from the active theme.
754
+ """
755
+ ct = app.current_theme # type: ignore[attr-defined]
756
+ dark: bool = ct.dark
757
+ base = DARK_COLORS if dark else LIGHT_COLORS
758
+
759
+ def _hex_or(val: str | None, fallback: str) -> str:
760
+ """Return `val` if it is a valid `#RRGGBB` hex color, else `fallback`.
761
+
762
+ Args:
763
+ val: Color string from the active Textual theme (may be `None` or
764
+ a non-hex name like `ansi_blue`).
765
+ fallback: Guaranteed-hex value from our base palette.
766
+
767
+ Returns:
768
+ `val` if it matches `#RRGGBB`, otherwise `fallback`.
769
+ """
770
+ if val is not None and _HEX_RE.match(val):
771
+ return val
772
+ return fallback
773
+
774
+ return ThemeColors(
775
+ primary=_hex_or(ct.primary, base.primary),
776
+ secondary=_hex_or(ct.secondary, base.secondary),
777
+ accent=_hex_or(ct.accent, base.accent),
778
+ panel=_hex_or(ct.panel, base.panel),
779
+ success=_hex_or(ct.success, base.success),
780
+ warning=_hex_or(ct.warning, base.warning),
781
+ error=_hex_or(ct.error, base.error),
782
+ muted=base.muted,
783
+ mode_bash=_hex_or(ct.error, base.mode_bash),
784
+ mode_command=_hex_or(ct.secondary, base.mode_command),
785
+ # No Textual equivalent — always use base palette.
786
+ skill=base.skill,
787
+ skill_hover=base.skill_hover,
788
+ # Derived from Textual's warning color (shared amber hue).
789
+ tool=_hex_or(ct.warning, base.tool),
790
+ # No Textual equivalent — always base palette (may diverge from
791
+ # tool in custom themes that override warning).
792
+ tool_hover=base.tool_hover,
793
+ foreground=_hex_or(ct.foreground, base.foreground),
794
+ background=_hex_or(ct.background, base.background),
795
+ surface=_hex_or(ct.surface, base.surface),
796
+ )
797
+
798
+
799
+ def get_theme_colors(widget_or_app: App | object | None = None) -> ThemeColors:
800
+ """Return the `ThemeColors` for the active Textual theme.
801
+
802
+ For custom themes (LangChain-branded and user-defined), the pre-built
803
+ `ThemeColors` from the registry is returned directly. For Textual built-in
804
+ themes, colors are resolved dynamically from the actual theme properties so
805
+ Python-side styling stays in sync with CSS variables.
806
+
807
+ Textual widget code should call this instead of reading the module-level
808
+ ANSI constants, which are intended for Rich console output only.
809
+
810
+ Args:
811
+ widget_or_app: Textual `App`, a mounted widget, or `None`.
812
+
813
+ Returns:
814
+ `ThemeColors` for the active theme.
815
+ """
816
+ if widget_or_app is None:
817
+ # Fall back to the active Textual app context var when no explicit
818
+ # widget/app is passed (e.g. from @staticmethod helpers).
819
+ try:
820
+ from textual._context import active_app # noqa: PLC2701
821
+
822
+ widget_or_app = active_app.get()
823
+ except (ImportError, LookupError):
824
+ return DARK_COLORS
825
+ app = _resolve_app(widget_or_app)
826
+ entry = ThemeEntry.REGISTRY.get(app.theme) # type: ignore[attr-defined]
827
+ # Custom themes (LC-branded / user-defined) use pre-built colors.
828
+ if entry is not None and entry.custom:
829
+ return entry.colors
830
+ # Built-in or unrecognized themes — derive from the resolved Textual
831
+ # theme so Python styling matches CSS.
832
+ try:
833
+ return _colors_from_textual_theme(app)
834
+ except Exception:
835
+ logger.warning("Could not resolve theme colors dynamically", exc_info=True)
836
+ if entry is not None:
837
+ return entry.colors
838
+ return DARK_COLORS