symphony-code 0.0.1__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 (169) hide show
  1. coding_agent/__init__.py +20 -0
  2. coding_agent/_lazy.py +17 -0
  3. coding_agent/addons/__init__.py +35 -0
  4. coding_agent/addons/approvals.py +224 -0
  5. coding_agent/addons/compaction/__init__.py +36 -0
  6. coding_agent/addons/compaction/addon.py +88 -0
  7. coding_agent/addons/compaction/compactor.py +138 -0
  8. coding_agent/addons/compaction/prompts.py +20 -0
  9. coding_agent/addons/compaction/transcript.py +118 -0
  10. coding_agent/addons/evaluation/__init__.py +68 -0
  11. coding_agent/addons/evaluation/addon.py +199 -0
  12. coding_agent/addons/evaluation/evaluators.py +245 -0
  13. coding_agent/addons/evaluation/handlers.py +66 -0
  14. coding_agent/addons/evaluation/policy.py +277 -0
  15. coding_agent/addons/evaluation/protocol.py +192 -0
  16. coding_agent/addons/evaluation/state.py +284 -0
  17. coding_agent/addons/langfuse/__init__.py +24 -0
  18. coding_agent/addons/langfuse/addon.py +465 -0
  19. coding_agent/addons/langfuse/install.py +79 -0
  20. coding_agent/addons/langfuse/serialize.py +158 -0
  21. coding_agent/addons/learning/__init__.py +33 -0
  22. coding_agent/addons/learning/addon.py +181 -0
  23. coding_agent/addons/learning/loop.py +247 -0
  24. coding_agent/addons/learning/prompts.py +26 -0
  25. coding_agent/addons/learning/sanitize.py +64 -0
  26. coding_agent/addons/learning/store.py +522 -0
  27. coding_agent/addons/manager/__init__.py +18 -0
  28. coding_agent/addons/manager/bridge.py +74 -0
  29. coding_agent/addons/manager/discovery.py +34 -0
  30. coding_agent/addons/manager/report.py +33 -0
  31. coding_agent/addons/manager/runtime.py +76 -0
  32. coding_agent/addons/manager/tool_changes.py +16 -0
  33. coding_agent/addons/persistence/__init__.py +41 -0
  34. coding_agent/addons/persistence/active.py +132 -0
  35. coding_agent/addons/persistence/artifacts.py +110 -0
  36. coding_agent/addons/persistence/collection.py +166 -0
  37. coding_agent/addons/persistence/compatibility.py +30 -0
  38. coding_agent/addons/persistence/images.py +133 -0
  39. coding_agent/addons/persistence/jsonl.py +1195 -0
  40. coding_agent/addons/persistence/presentation.py +104 -0
  41. coding_agent/addons/plugins/__init__.py +19 -0
  42. coding_agent/addons/plugins/install.py +37 -0
  43. coding_agent/addons/plugins/manager.py +140 -0
  44. coding_agent/addons/plugins/models.py +50 -0
  45. coding_agent/addons/skills/__init__.py +28 -0
  46. coding_agent/addons/skills/addon.py +26 -0
  47. coding_agent/addons/skills/args.py +78 -0
  48. coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
  49. coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
  50. coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
  51. coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
  52. coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
  53. coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
  54. coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
  55. coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
  56. coding_agent/addons/skills/frontmatter.py +57 -0
  57. coding_agent/addons/skills/models.py +57 -0
  58. coding_agent/addons/skills/registry.py +109 -0
  59. coding_agent/addons/skills/tool.py +54 -0
  60. coding_agent/addons/voice/__init__.py +5 -0
  61. coding_agent/addons/voice/addon.py +106 -0
  62. coding_agent/addons/voice/credentials.py +42 -0
  63. coding_agent/addons/voice/playback.py +42 -0
  64. coding_agent/addons/voice/speak.py +287 -0
  65. coding_agent/addons/voice/stream.py +651 -0
  66. coding_agent/addons/voice/summary.py +126 -0
  67. coding_agent/agent.py +523 -0
  68. coding_agent/bench/__init__.py +1 -0
  69. coding_agent/bench/cli.py +200 -0
  70. coding_agent/bench/patch.py +78 -0
  71. coding_agent/commands/__init__.py +5 -0
  72. coding_agent/commands/catalog.py +179 -0
  73. coding_agent/commands/jev_argument.py +40 -0
  74. coding_agent/config/__init__.py +51 -0
  75. coding_agent/config/config.py +376 -0
  76. coding_agent/config/credentials.py +153 -0
  77. coding_agent/config/personalities.json +34 -0
  78. coding_agent/config/personalities.py +170 -0
  79. coding_agent/config/prompts.py +67 -0
  80. coding_agent/protocols/__init__.py +5 -0
  81. coding_agent/protocols/acp/README.md +36 -0
  82. coding_agent/protocols/acp/__init__.py +27 -0
  83. coding_agent/protocols/acp/mapping.py +164 -0
  84. coding_agent/protocols/acp/server.py +261 -0
  85. coding_agent/protocols/acp/transport.py +72 -0
  86. coding_agent/protocols/stdio/README.md +36 -0
  87. coding_agent/protocols/stdio/__init__.py +37 -0
  88. coding_agent/protocols/stdio/catalog.py +49 -0
  89. coding_agent/protocols/stdio/commands.py +218 -0
  90. coding_agent/protocols/stdio/server.py +181 -0
  91. coding_agent/protocols/stdio/transport.py +71 -0
  92. coding_agent/py.typed +0 -0
  93. coding_agent/resources/__init__.py +5 -0
  94. coding_agent/resources/usage.py +264 -0
  95. coding_agent/run/__init__.py +1 -0
  96. coding_agent/run/cli.py +99 -0
  97. coding_agent/run/detach.py +37 -0
  98. coding_agent/run/interrupted.py +67 -0
  99. coding_agent/run/log_sink.py +46 -0
  100. coding_agent/tools/__init__.py +73 -0
  101. coding_agent/tools/ask_user.py +61 -0
  102. coding_agent/tools/base.py +136 -0
  103. coding_agent/tools/bash.py +288 -0
  104. coding_agent/tools/bash_jobs.py +116 -0
  105. coding_agent/tools/generate_image.py +123 -0
  106. coding_agent/tools/memory.py +100 -0
  107. coding_agent/tools/patch.py +218 -0
  108. coding_agent/tools/read_file.py +167 -0
  109. coding_agent/tools/search.py +433 -0
  110. coding_agent/tools/web_search.py +151 -0
  111. coding_agent/tools/write_file.py +40 -0
  112. coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
  113. coding_agent/tui/__init__.py +22 -0
  114. coding_agent/tui/__main__.py +122 -0
  115. coding_agent/tui/app.py +456 -0
  116. coding_agent/tui/chrome/__init__.py +33 -0
  117. coding_agent/tui/chrome/footer.py +274 -0
  118. coding_agent/tui/chrome/topbar.py +259 -0
  119. coding_agent/tui/commands/__init__.py +45 -0
  120. coding_agent/tui/commands/catalog.py +35 -0
  121. coding_agent/tui/commands/manager.py +557 -0
  122. coding_agent/tui/composer/__init__.py +18 -0
  123. coding_agent/tui/composer/input.py +219 -0
  124. coding_agent/tui/composer/slash_menu.py +341 -0
  125. coding_agent/tui/composer/surface.py +330 -0
  126. coding_agent/tui/composer/voice.py +267 -0
  127. coding_agent/tui/composer/voice_mode.py +491 -0
  128. coding_agent/tui/runtime/__init__.py +23 -0
  129. coding_agent/tui/runtime/events.py +728 -0
  130. coding_agent/tui/runtime/sink.py +115 -0
  131. coding_agent/tui/runtime/state.py +145 -0
  132. coding_agent/tui/runtime/subagent.py +531 -0
  133. coding_agent/tui/runtime/turn.py +206 -0
  134. coding_agent/tui/screens/__init__.py +54 -0
  135. coding_agent/tui/screens/ask.py +69 -0
  136. coding_agent/tui/screens/context.py +195 -0
  137. coding_agent/tui/screens/dashboard.py +91 -0
  138. coding_agent/tui/screens/diff.py +302 -0
  139. coding_agent/tui/screens/extensions.py +68 -0
  140. coding_agent/tui/screens/file_selector.py +167 -0
  141. coding_agent/tui/screens/history.py +458 -0
  142. coding_agent/tui/screens/langfuse.py +142 -0
  143. coding_agent/tui/screens/learning.py +113 -0
  144. coding_agent/tui/screens/modal.py +131 -0
  145. coding_agent/tui/screens/onboard.py +455 -0
  146. coding_agent/tui/screens/resume.py +163 -0
  147. coding_agent/tui/theme/__init__.py +49 -0
  148. coding_agent/tui/theme/load.py +325 -0
  149. coding_agent/tui/theme/style.py +95 -0
  150. coding_agent/tui/theme/theme.toml +1687 -0
  151. coding_agent/tui/tools/__init__.py +53 -0
  152. coding_agent/tui/tools/activity.py +184 -0
  153. coding_agent/tui/tools/calls.py +560 -0
  154. coding_agent/tui/tools/code_view.py +52 -0
  155. coding_agent/tui/tools/images.py +250 -0
  156. coding_agent/tui/tools/labels.py +135 -0
  157. coding_agent/tui/tools/modal.py +137 -0
  158. coding_agent/tui/tools/partial_args.py +19 -0
  159. coding_agent/tui/tools/snapshots.py +382 -0
  160. coding_agent/tui/transcript/__init__.py +36 -0
  161. coding_agent/tui/transcript/messages.py +408 -0
  162. coding_agent/tui/transcript/process.py +504 -0
  163. coding_agent/tui/transcript/surface.py +345 -0
  164. coding_agent/tui/transcript/thinking_markdown.py +314 -0
  165. symphony_code-0.0.1.dist-info/METADATA +66 -0
  166. symphony_code-0.0.1.dist-info/RECORD +169 -0
  167. symphony_code-0.0.1.dist-info/WHEEL +4 -0
  168. symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
  169. symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,163 @@
1
+ """Pre-launch selector for a persisted conversation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+ from rich.cells import cell_len
10
+ from rich.console import Group
11
+ from rich.text import Text
12
+ from textual import events
13
+ from textual.app import App, ComposeResult
14
+ from textual.binding import Binding
15
+ from textual.containers import Container
16
+ from textual.widgets import OptionList, Static
17
+ from textual.widgets.option_list import Option
18
+
19
+ from coding_agent.addons.persistence.active import active_session_ids
20
+ from coding_agent.tui.theme import RESUME_CSS, SYMPHONY_RICH_THEME
21
+ from core_ai.content import text_from_content
22
+ from core_harness.context import COMPACTED_CONTEXT_MARK
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SessionOption:
27
+ session_id: str
28
+ updated_at: str
29
+ first_message: str
30
+ message_count: int
31
+
32
+
33
+ def _relative_time(value: str) -> str:
34
+ try:
35
+ updated = datetime.fromisoformat(value.replace("Z", "+00:00"))
36
+ except ValueError:
37
+ return value
38
+ seconds = max(0, int((datetime.now(timezone.utc) - updated).total_seconds()))
39
+ if seconds < 60:
40
+ return "just now"
41
+ if seconds < 3_600:
42
+ return f"{seconds // 60}m ago"
43
+ if seconds < 86_400:
44
+ return f"{seconds // 3_600}h ago"
45
+ return f"{seconds // 86_400}d ago"
46
+
47
+
48
+ async def load_session_options(
49
+ persistence: Any,
50
+ *,
51
+ closed_only: bool = True,
52
+ ) -> list[SessionOption]:
53
+ """Build display rows through the existing persistence interface.
54
+
55
+ Active sessions are owned by a live Symphony process and listed under
56
+ ``~/.symphony/active``. The resume picker hides them so a conversation is
57
+ not opened twice. Pass ``closed_only=False`` to include them.
58
+ """
59
+ open_ids = active_session_ids() if closed_only else set()
60
+ options: list[SessionOption] = []
61
+ for session in await persistence.list_sessions():
62
+ if session.session_id in open_ids:
63
+ continue
64
+ # JsonlPersistence computes these cheap row fields while scanning each
65
+ # file. Avoid reparsing every transcript when opening the picker.
66
+ if getattr(session, "message_count", 0) or getattr(session, "first_message", ""):
67
+ first_message = getattr(session, "first_message", "") or "Untitled conversation"
68
+ message_count = getattr(session, "message_count", 0)
69
+ else:
70
+ loader = getattr(persistence, "load_transcript", persistence.load_conversation)
71
+ messages = await loader(session_id=session.session_id)
72
+ first_message = next(
73
+ (
74
+ text_from_content(message.content).strip()
75
+ for message in messages
76
+ if message.role == "user"
77
+ and message.content
78
+ and not text_from_content(message.content).startswith(COMPACTED_CONTEXT_MARK)
79
+ ),
80
+ "Untitled conversation",
81
+ )
82
+ message_count = len(messages)
83
+ options.append(
84
+ SessionOption(session.session_id, session.updated_at, first_message, message_count)
85
+ )
86
+ return options
87
+
88
+
89
+ def _fit(text: str, width: int) -> str:
90
+ """Clip by terminal cells, reserving one cell for an ellipsis."""
91
+ if width <= 0:
92
+ return ""
93
+ if cell_len(text) <= width:
94
+ return text
95
+ if width == 1:
96
+ return "…"
97
+ kept = text
98
+ while kept and cell_len(kept) > width - 1:
99
+ kept = kept[:-1]
100
+ return f"{kept}…"
101
+
102
+
103
+ def _row(session: SessionOption, width: int) -> Option:
104
+ """Title on line one and aligned metadata on line two.
105
+
106
+ Both lines are pre-clipped. Textual otherwise wraps long titles while
107
+ measuring every session, which makes a long resume list slow to open.
108
+ """
109
+ title_width = max(1, width - 3)
110
+ meta_width = max(1, width - 3)
111
+ title = _fit(session.first_message.replace("\n", " "), title_width)
112
+ meta = _fit(
113
+ f"{_relative_time(session.updated_at):<8} {session.message_count:>6} {session.session_id}",
114
+ meta_width,
115
+ )
116
+ heading = Text(f"› {title}", no_wrap=True, overflow="ellipsis")
117
+ heading.highlight_words(["›"], style="bold #e1c16e")
118
+ details = Text(f" {meta}", no_wrap=True, overflow="ellipsis", style="#737373")
119
+ return Option(Group(heading, details), id=session.session_id)
120
+
121
+
122
+ class ResumeApp(App[str | None]):
123
+ """Choose a session before the conversation UI is created."""
124
+
125
+ CSS = RESUME_CSS
126
+ BINDINGS = [
127
+ Binding("escape", "cancel", "quit"),
128
+ Binding("ctrl+c", "cancel", "quit", show=False),
129
+ ]
130
+
131
+ def __init__(self, sessions: list[SessionOption]) -> None:
132
+ super().__init__()
133
+ self.sessions = sessions
134
+ self._row_width = 0
135
+
136
+ def compose(self) -> ComposeResult:
137
+ with Container(id="resume-page"):
138
+ yield Static("Resume a previous session", id="resume-title")
139
+ yield Static("Select a conversation to continue", id="resume-subtitle")
140
+ yield OptionList(id="resume-list")
141
+ yield Static("↑↓ select Enter resume Esc quit", id="resume-hint")
142
+
143
+ def on_resize(self, event: events.Resize) -> None:
144
+ # Page padding is 2, row padding is 4, and the scrollbar takes 1.
145
+ width = max(1, event.size.width - 7)
146
+ if width == self._row_width:
147
+ return
148
+ self._row_width = width
149
+ options = self.query_one("#resume-list", OptionList)
150
+ highlighted = options.highlighted
151
+ options.set_options(_row(session, width) for session in self.sessions)
152
+ if self.sessions:
153
+ options.highlighted = min(highlighted or 0, len(self.sessions) - 1)
154
+
155
+ def on_mount(self) -> None:
156
+ self.console.push_theme(SYMPHONY_RICH_THEME, inherit=True)
157
+ self.query_one("#resume-list", OptionList).focus()
158
+
159
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
160
+ self.exit(event.option_id)
161
+
162
+ def action_cancel(self) -> None:
163
+ self.exit(None)
@@ -0,0 +1,49 @@
1
+ """Color tokens and Textual CSS for the coding-agent TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from coding_agent.tui.theme.load import (
6
+ APP_CSS,
7
+ CHROME_CSS,
8
+ COMPOSER_CSS,
9
+ CONTENT_MODAL_CSS,
10
+ CONTEXT_MODAL_CSS,
11
+ DIFF_MODAL_CSS,
12
+ EXTENSIONS_MODAL_CSS,
13
+ IMAGE_MODAL_CSS,
14
+ LEARNING_MODAL_CSS,
15
+ MODAL_BASE_CSS,
16
+ ONBOARD_CSS,
17
+ PROVIDER_MODAL_CSS,
18
+ RESUME_CSS,
19
+ SYMPHONY_COLORS,
20
+ TOOLS_CSS,
21
+ )
22
+ from coding_agent.tui.theme.style import (
23
+ SYMPHONY_CODE_THEME,
24
+ SYMPHONY_RICH_THEME,
25
+ SymphonyCodeStyle,
26
+ themed_markdown,
27
+ )
28
+
29
+ __all__ = [
30
+ "APP_CSS",
31
+ "CHROME_CSS",
32
+ "COMPOSER_CSS",
33
+ "CONTENT_MODAL_CSS",
34
+ "CONTEXT_MODAL_CSS",
35
+ "DIFF_MODAL_CSS",
36
+ "EXTENSIONS_MODAL_CSS",
37
+ "IMAGE_MODAL_CSS",
38
+ "LEARNING_MODAL_CSS",
39
+ "MODAL_BASE_CSS",
40
+ "ONBOARD_CSS",
41
+ "PROVIDER_MODAL_CSS",
42
+ "RESUME_CSS",
43
+ "SYMPHONY_CODE_THEME",
44
+ "SYMPHONY_COLORS",
45
+ "SYMPHONY_RICH_THEME",
46
+ "SymphonyCodeStyle",
47
+ "TOOLS_CSS",
48
+ "themed_markdown",
49
+ ]
@@ -0,0 +1,325 @@
1
+ """Load the Symphony TUI theme from ~/.symphony/theme.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import tomllib
7
+ from dataclasses import dataclass
8
+ from importlib.resources import files
9
+ from pathlib import Path
10
+ from typing import Any, Mapping
11
+
12
+ THEME_FILE = "theme.toml"
13
+ PACKAGE_NAME = "coding_agent.tui.theme"
14
+
15
+ SYMPHONY_COLOR_KEYS = (
16
+ "background",
17
+ "foreground",
18
+ "accent",
19
+ "muted",
20
+ "muted_dim",
21
+ "edge",
22
+ "comment",
23
+ "keyword",
24
+ "type_keywords",
25
+ "string",
26
+ "function",
27
+ "variable",
28
+ "number",
29
+ "operator",
30
+ "punctuation",
31
+ "type",
32
+ "tag",
33
+ "attribute",
34
+ "constant",
35
+ "surface",
36
+ "overlay",
37
+ "subtext",
38
+ )
39
+
40
+ SPACING_PREFIX = "space-"
41
+
42
+ CSS_SECTIONS = ("chrome", "tools", "composer", "resume", "onboard")
43
+ MODAL_SECTIONS = (
44
+ "base",
45
+ "content",
46
+ "image",
47
+ "diff",
48
+ "extensions",
49
+ "learning",
50
+ "context",
51
+ "provider",
52
+ )
53
+
54
+
55
+ class ThemeConfigError(ValueError):
56
+ """theme.toml is missing a required table, key, or color token."""
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class ThemeDocument:
61
+ """Parsed theme.toml plus the assembled CSS strings the TUI imports."""
62
+
63
+ source: str
64
+ colors: dict[str, str]
65
+ spacing: dict[str, int]
66
+ chrome_css: str
67
+ tools_css: str
68
+ composer_css: str
69
+ resume_css: str
70
+ onboard_css: str
71
+ modal_base_css: str
72
+ content_modal_css: str
73
+ image_modal_css: str
74
+ diff_modal_css: str
75
+ extensions_modal_css: str
76
+ learning_modal_css: str
77
+ context_modal_css: str
78
+ provider_modal_css: str
79
+
80
+ @property
81
+ def app_css(self) -> str:
82
+ return glue_css(glue_css(self.chrome_css, self.tools_css), self.composer_css)
83
+
84
+
85
+ def user_theme_path() -> Path:
86
+ """Runtime theme file: ~/.symphony/theme.toml."""
87
+ return Path.home() / ".symphony" / THEME_FILE
88
+
89
+
90
+ def packaged_theme_path() -> Path:
91
+ """Filesystem path of the theme.toml shipped next to this package."""
92
+ return Path(__file__).with_name(THEME_FILE)
93
+
94
+
95
+ def resolve_theme_path(path: Path | None = None) -> Path:
96
+ """Prefer an explicit path, then ~/.symphony/theme.toml, then the package copy."""
97
+ if path is not None:
98
+ resolved = Path(path)
99
+ if not resolved.is_file():
100
+ raise ThemeConfigError(f"theme file not found: {resolved}")
101
+ return resolved
102
+ user = user_theme_path()
103
+ if user.is_file():
104
+ return user
105
+ packaged = packaged_theme_path()
106
+ if packaged.is_file():
107
+ return packaged
108
+ raise ThemeConfigError(
109
+ "theme.toml not found (no packaged copy and no ~/.symphony/theme.toml)"
110
+ )
111
+
112
+
113
+ def load_theme(path: Path | None = None) -> ThemeDocument:
114
+ """Parse theme.toml and assemble the public color/CSS surface."""
115
+ source, raw = _read_theme(path)
116
+ document = _parse_toml(source, raw)
117
+ colors = color_tokens(document)
118
+ css = _table(document, "css", source=source)
119
+ variables = _string_table(_table(css, "variables", source=source, label="css.variables"))
120
+ spacing = spacing_tokens(css)
121
+ sections = {name: _css_string(css, name, source=source) for name in CSS_SECTIONS}
122
+ modal = _table(css, "modal", source=source, label="css.modal")
123
+ modal_css = {name: _css_string(modal, name, source=source, label=f"css.modal.{name}") for name in MODAL_SECTIONS}
124
+ chrome = textual_variable_block(colors, variables) + spacing_variable_block(spacing) + sections["chrome"]
125
+ base = modal_css["base"]
126
+ return ThemeDocument(
127
+ source=source,
128
+ colors={key: colors[key] for key in SYMPHONY_COLOR_KEYS},
129
+ spacing=spacing,
130
+ chrome_css=chrome,
131
+ tools_css=sections["tools"],
132
+ composer_css=sections["composer"],
133
+ resume_css=sections["resume"],
134
+ onboard_css=glue_css(sections["resume"], sections["onboard"]),
135
+ modal_base_css=base,
136
+ content_modal_css=glue_css(base, modal_css["content"]),
137
+ image_modal_css=glue_css(base, modal_css["image"]),
138
+ diff_modal_css=glue_css(base, modal_css["diff"]),
139
+ extensions_modal_css=glue_css(base, modal_css["extensions"]),
140
+ learning_modal_css=glue_css(base, modal_css["learning"]),
141
+ context_modal_css=glue_css(base, modal_css["context"]),
142
+ provider_modal_css=glue_css(base, modal_css["provider"]),
143
+ )
144
+
145
+
146
+ def glue_css(prefix: str, extra: str) -> str:
147
+ """Join two CSS fragments with a blank line, matching the old Python constants."""
148
+ if extra.startswith("\n"):
149
+ return prefix + extra
150
+ if prefix.endswith("\n\n"):
151
+ return prefix + extra
152
+ if prefix.endswith("\n"):
153
+ return prefix + "\n" + extra
154
+ return prefix + "\n\n" + extra
155
+
156
+
157
+ def color_tokens(document: Mapping[str, Any]) -> dict[str, str]:
158
+ """Return every [colors] entry; required Symphony tokens must be present."""
159
+ colors = _string_table(_table(document, "colors"))
160
+ missing = [key for key in SYMPHONY_COLOR_KEYS if key not in colors]
161
+ if missing:
162
+ raise ThemeConfigError("theme.toml [colors] missing: " + ", ".join(missing))
163
+ return colors
164
+
165
+
166
+ def textual_variable_block(colors: Mapping[str, str], variables: Mapping[str, str]) -> str:
167
+ """Render Textual `$name: value;` declarations from [css.variables]."""
168
+ if not variables:
169
+ raise ThemeConfigError("theme.toml [css.variables] is empty")
170
+ lines = ["/* Symphony dark theme. Palette tokens live in theme.toml. */"]
171
+ for name, token in variables.items():
172
+ if token not in colors:
173
+ raise ThemeConfigError(f"[css.variables].{name} refers to unknown color {token!r}")
174
+ lines.append(f"${name}: {colors[token]};")
175
+ return "\n".join(lines) + "\n\n"
176
+
177
+
178
+ def spacing_tokens(css: Mapping[str, Any]) -> dict[str, int]:
179
+ """Return [css.spacing] as cell counts; a theme without the table has no tokens."""
180
+ table = css.get("spacing")
181
+ if table is None:
182
+ return {}
183
+ if not isinstance(table, dict):
184
+ raise ThemeConfigError("theme.toml [css.spacing] must be a table")
185
+ tokens: dict[str, int] = {}
186
+ for key, cells in table.items():
187
+ if isinstance(cells, bool) or not isinstance(cells, int) or cells < 0:
188
+ raise ThemeConfigError(
189
+ f"theme.toml [css.spacing].{key} must be a non-negative integer"
190
+ )
191
+ tokens[str(key)] = cells
192
+ return tokens
193
+
194
+
195
+ def spacing_variable_block(spacing: Mapping[str, int]) -> str:
196
+ """Render spacing tokens as Textual `$space-<name>: <cells>;` declarations."""
197
+ if not spacing:
198
+ return ""
199
+ lines = ["/* Spacing scale in terminal cells. Values live in theme.toml [css.spacing]. */"]
200
+ lines.extend(f"${SPACING_PREFIX}{name}: {cells};" for name, cells in spacing.items())
201
+ return "\n".join(lines) + "\n\n"
202
+
203
+
204
+ def _read_theme(path: Path | None) -> tuple[str, str]:
205
+ if path is not None or user_theme_path().is_file() or packaged_theme_path().is_file():
206
+ resolved = resolve_theme_path(path)
207
+ return str(resolved), resolved.read_text(encoding="utf-8")
208
+ resource = files(PACKAGE_NAME).joinpath(THEME_FILE)
209
+ return f"package:{THEME_FILE}", resource.read_text(encoding="utf-8")
210
+
211
+
212
+ def _parse_toml(source: str, raw: str) -> dict[str, Any]:
213
+ try:
214
+ document = tomllib.loads(raw)
215
+ except tomllib.TOMLDecodeError as exc:
216
+ raise ThemeConfigError(f"invalid TOML in {source}: {exc}") from exc
217
+ if not isinstance(document, dict):
218
+ raise ThemeConfigError(f"{source} must be a TOML table")
219
+ return document
220
+
221
+
222
+ def _table(
223
+ document: Mapping[str, Any],
224
+ key: str,
225
+ *,
226
+ source: str = "theme.toml",
227
+ label: str | None = None,
228
+ ) -> dict[str, Any]:
229
+ name = label or key
230
+ value = document.get(key)
231
+ if not isinstance(value, dict):
232
+ raise ThemeConfigError(f"{source} is missing [{name}]")
233
+ return value
234
+
235
+
236
+ def _string_table(table: Mapping[str, Any]) -> dict[str, str]:
237
+ values: dict[str, str] = {}
238
+ for key, value in table.items():
239
+ if not isinstance(value, str) or not value:
240
+ raise ThemeConfigError(f"theme.toml {key!r} must be a non-empty string")
241
+ values[str(key)] = value
242
+ return values
243
+
244
+
245
+ def _css_string(
246
+ table: Mapping[str, Any],
247
+ key: str,
248
+ *,
249
+ source: str,
250
+ label: str | None = None,
251
+ ) -> str:
252
+ name = label or f"css.{key}"
253
+ value = table.get(key)
254
+ if not isinstance(value, str) or not value.strip():
255
+ raise ThemeConfigError(f"{source} is missing a non-empty [{name}] string")
256
+ return value
257
+
258
+
259
+ _DEFAULT = load_theme()
260
+
261
+ # Mutable on purpose. Chrome reads these keys while it paints, so a live
262
+ # reload can replace the values without restarting the process. Do not
263
+ # rebind the dict; callers hold this same object.
264
+ SYMPHONY_COLORS = dict(_DEFAULT.colors)
265
+ CHROME_CSS = _DEFAULT.chrome_css
266
+ TOOLS_CSS = _DEFAULT.tools_css
267
+ COMPOSER_CSS = _DEFAULT.composer_css
268
+ RESUME_CSS = _DEFAULT.resume_css
269
+ ONBOARD_CSS = _DEFAULT.onboard_css
270
+ MODAL_BASE_CSS = _DEFAULT.modal_base_css
271
+ CONTENT_MODAL_CSS = _DEFAULT.content_modal_css
272
+ IMAGE_MODAL_CSS = _DEFAULT.image_modal_css
273
+ DIFF_MODAL_CSS = _DEFAULT.diff_modal_css
274
+ EXTENSIONS_MODAL_CSS = _DEFAULT.extensions_modal_css
275
+ LEARNING_MODAL_CSS = _DEFAULT.learning_modal_css
276
+ CONTEXT_MODAL_CSS = _DEFAULT.context_modal_css
277
+ PROVIDER_MODAL_CSS = _DEFAULT.provider_modal_css
278
+ APP_CSS = _DEFAULT.app_css
279
+
280
+
281
+ def apply_theme(document: ThemeDocument) -> None:
282
+ """Publish a parsed theme into the module globals the running TUI holds.
283
+
284
+ ``SYMPHONY_COLORS`` is updated in place so widgets that imported the dict
285
+ see the new tokens on their next paint. CSS strings are replaced for the
286
+ next ``stylesheet`` read; already-mounted screens must reassign theirs.
287
+ """
288
+ SYMPHONY_COLORS.clear()
289
+ SYMPHONY_COLORS.update(document.colors)
290
+ global CHROME_CSS, TOOLS_CSS, COMPOSER_CSS, RESUME_CSS, ONBOARD_CSS
291
+ global MODAL_BASE_CSS, CONTENT_MODAL_CSS, IMAGE_MODAL_CSS, DIFF_MODAL_CSS
292
+ global EXTENSIONS_MODAL_CSS, LEARNING_MODAL_CSS
293
+ global CONTEXT_MODAL_CSS, PROVIDER_MODAL_CSS, APP_CSS
294
+ CHROME_CSS = document.chrome_css
295
+ TOOLS_CSS = document.tools_css
296
+ COMPOSER_CSS = document.composer_css
297
+ RESUME_CSS = document.resume_css
298
+ ONBOARD_CSS = document.onboard_css
299
+ MODAL_BASE_CSS = document.modal_base_css
300
+ CONTENT_MODAL_CSS = document.content_modal_css
301
+ IMAGE_MODAL_CSS = document.image_modal_css
302
+ DIFF_MODAL_CSS = document.diff_modal_css
303
+ EXTENSIONS_MODAL_CSS = document.extensions_modal_css
304
+ LEARNING_MODAL_CSS = document.learning_modal_css
305
+ CONTEXT_MODAL_CSS = document.context_modal_css
306
+ PROVIDER_MODAL_CSS = document.provider_modal_css
307
+ APP_CSS = document.app_css
308
+
309
+
310
+ def theme_signature(path: Path | None = None) -> tuple[str, tuple[int, int, str]] | None:
311
+ """Identity of the theme file currently in effect, or None when unreadable.
312
+
313
+ The second item is ``(st_mtime_ns, st_size, sha256 of the bytes)``. The
314
+ mtime alone misses a rewrite that lands in the same filesystem timestamp
315
+ tick, and the size misses a same-length edit such as a changed hex color,
316
+ so the content digest decides those. The file is a few KB, so hashing it
317
+ on every poll is cheap.
318
+ """
319
+ try:
320
+ resolved = resolve_theme_path(path)
321
+ stat = resolved.stat()
322
+ digest = hashlib.sha256(resolved.read_bytes()).hexdigest()
323
+ except (OSError, ThemeConfigError):
324
+ return None
325
+ return (str(resolved), (stat.st_mtime_ns, stat.st_size, digest))
@@ -0,0 +1,95 @@
1
+ """Rich and Pygments glue for tokens loaded from theme.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pygments.style import Style
6
+ from pygments.token import (
7
+ Comment,
8
+ Error,
9
+ Generic,
10
+ Keyword,
11
+ Name,
12
+ Number,
13
+ Operator,
14
+ Punctuation,
15
+ String,
16
+ Text,
17
+ )
18
+ from rich.markdown import Markdown
19
+ from rich.syntax import PygmentsSyntaxTheme
20
+ from rich.theme import Theme
21
+
22
+ from coding_agent.tui.theme.load import SYMPHONY_COLORS
23
+
24
+
25
+ class SymphonyCodeStyle(Style):
26
+ """Syntax palette for the active Symphony dark theme."""
27
+
28
+ background_color = SYMPHONY_COLORS["background"]
29
+ highlight_color = SYMPHONY_COLORS["overlay"]
30
+ line_number_color = SYMPHONY_COLORS["comment"]
31
+ line_number_background_color = SYMPHONY_COLORS["background"]
32
+ line_number_special_color = SYMPHONY_COLORS["foreground"]
33
+ line_number_special_background_color = SYMPHONY_COLORS["overlay"]
34
+
35
+ styles = {
36
+ Text: SYMPHONY_COLORS["foreground"],
37
+ Text.Whitespace: SYMPHONY_COLORS["punctuation"],
38
+ Comment: "italic " + SYMPHONY_COLORS["comment"],
39
+ Comment.Preproc: SYMPHONY_COLORS["comment"],
40
+ Keyword: SYMPHONY_COLORS["keyword"],
41
+ Keyword.Type: SYMPHONY_COLORS["type_keywords"],
42
+ Operator: SYMPHONY_COLORS["operator"],
43
+ Punctuation: SYMPHONY_COLORS["punctuation"],
44
+ Name: SYMPHONY_COLORS["variable"],
45
+ Name.Builtin: SYMPHONY_COLORS["constant"],
46
+ Name.Class: "bold " + SYMPHONY_COLORS["type"],
47
+ Name.Decorator: SYMPHONY_COLORS["attribute"],
48
+ Name.Exception: SYMPHONY_COLORS["keyword"],
49
+ Name.Function: SYMPHONY_COLORS["function"],
50
+ Name.Namespace: SYMPHONY_COLORS["type"],
51
+ Name.Tag: SYMPHONY_COLORS["tag"],
52
+ Name.Variable: SYMPHONY_COLORS["variable"],
53
+ String: SYMPHONY_COLORS["string"],
54
+ String.Doc: "italic " + SYMPHONY_COLORS["comment"],
55
+ Number: SYMPHONY_COLORS["number"],
56
+ Generic.Deleted: SYMPHONY_COLORS["keyword"] + " bg:" + SYMPHONY_COLORS["overlay"],
57
+ Generic.Inserted: SYMPHONY_COLORS["string"] + " bg:" + SYMPHONY_COLORS["surface"],
58
+ Generic.Heading: "bold " + SYMPHONY_COLORS["foreground"],
59
+ Generic.Subheading: SYMPHONY_COLORS["subtext"],
60
+ Error: SYMPHONY_COLORS["keyword"] + " bg:" + SYMPHONY_COLORS["overlay"],
61
+ }
62
+
63
+
64
+ SYMPHONY_CODE_THEME = PygmentsSyntaxTheme(SymphonyCodeStyle)
65
+
66
+ SYMPHONY_RICH_THEME = Theme(
67
+ {
68
+ "markdown.paragraph": SYMPHONY_COLORS["foreground"],
69
+ "markdown.text": SYMPHONY_COLORS["foreground"],
70
+ "markdown.h1": "bold " + SYMPHONY_COLORS["foreground"],
71
+ "markdown.h2": "bold " + SYMPHONY_COLORS["attribute"],
72
+ "markdown.h3": "bold " + SYMPHONY_COLORS["tag"],
73
+ "markdown.h4": "italic " + SYMPHONY_COLORS["subtext"],
74
+ "markdown.h5": "italic " + SYMPHONY_COLORS["comment"],
75
+ "markdown.h6": "dim " + SYMPHONY_COLORS["comment"],
76
+ "markdown.block_quote": SYMPHONY_COLORS["string"],
77
+ "markdown.list": SYMPHONY_COLORS["foreground"],
78
+ "markdown.item.bullet": "bold " + SYMPHONY_COLORS["function"],
79
+ "markdown.item.number": SYMPHONY_COLORS["function"],
80
+ "markdown.code": "bold " + SYMPHONY_COLORS["tag"],
81
+ "markdown.link": SYMPHONY_COLORS["function"],
82
+ "markdown.link_url": "underline " + SYMPHONY_COLORS["attribute"],
83
+ "markdown.table.border": SYMPHONY_COLORS["punctuation"],
84
+ "markdown.table.header": "bold " + SYMPHONY_COLORS["attribute"],
85
+ "markdown.kbd": "bold " + SYMPHONY_COLORS["type"],
86
+ },
87
+ inherit=True,
88
+ )
89
+
90
+
91
+ def themed_markdown(markup: str, **kwargs: object) -> Markdown:
92
+ """Build Rich Markdown using Symphony's syntax theme (not Rich's monokai default)."""
93
+ kwargs.setdefault("code_theme", SYMPHONY_CODE_THEME)
94
+ kwargs.setdefault("inline_code_theme", SYMPHONY_CODE_THEME)
95
+ return Markdown(markup, **kwargs) # type: ignore[arg-type]