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.
- coding_agent/__init__.py +20 -0
- coding_agent/_lazy.py +17 -0
- coding_agent/addons/__init__.py +35 -0
- coding_agent/addons/approvals.py +224 -0
- coding_agent/addons/compaction/__init__.py +36 -0
- coding_agent/addons/compaction/addon.py +88 -0
- coding_agent/addons/compaction/compactor.py +138 -0
- coding_agent/addons/compaction/prompts.py +20 -0
- coding_agent/addons/compaction/transcript.py +118 -0
- coding_agent/addons/evaluation/__init__.py +68 -0
- coding_agent/addons/evaluation/addon.py +199 -0
- coding_agent/addons/evaluation/evaluators.py +245 -0
- coding_agent/addons/evaluation/handlers.py +66 -0
- coding_agent/addons/evaluation/policy.py +277 -0
- coding_agent/addons/evaluation/protocol.py +192 -0
- coding_agent/addons/evaluation/state.py +284 -0
- coding_agent/addons/langfuse/__init__.py +24 -0
- coding_agent/addons/langfuse/addon.py +465 -0
- coding_agent/addons/langfuse/install.py +79 -0
- coding_agent/addons/langfuse/serialize.py +158 -0
- coding_agent/addons/learning/__init__.py +33 -0
- coding_agent/addons/learning/addon.py +181 -0
- coding_agent/addons/learning/loop.py +247 -0
- coding_agent/addons/learning/prompts.py +26 -0
- coding_agent/addons/learning/sanitize.py +64 -0
- coding_agent/addons/learning/store.py +522 -0
- coding_agent/addons/manager/__init__.py +18 -0
- coding_agent/addons/manager/bridge.py +74 -0
- coding_agent/addons/manager/discovery.py +34 -0
- coding_agent/addons/manager/report.py +33 -0
- coding_agent/addons/manager/runtime.py +76 -0
- coding_agent/addons/manager/tool_changes.py +16 -0
- coding_agent/addons/persistence/__init__.py +41 -0
- coding_agent/addons/persistence/active.py +132 -0
- coding_agent/addons/persistence/artifacts.py +110 -0
- coding_agent/addons/persistence/collection.py +166 -0
- coding_agent/addons/persistence/compatibility.py +30 -0
- coding_agent/addons/persistence/images.py +133 -0
- coding_agent/addons/persistence/jsonl.py +1195 -0
- coding_agent/addons/persistence/presentation.py +104 -0
- coding_agent/addons/plugins/__init__.py +19 -0
- coding_agent/addons/plugins/install.py +37 -0
- coding_agent/addons/plugins/manager.py +140 -0
- coding_agent/addons/plugins/models.py +50 -0
- coding_agent/addons/skills/__init__.py +28 -0
- coding_agent/addons/skills/addon.py +26 -0
- coding_agent/addons/skills/args.py +78 -0
- coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
- coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
- coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
- coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
- coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
- coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
- coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
- coding_agent/addons/skills/frontmatter.py +57 -0
- coding_agent/addons/skills/models.py +57 -0
- coding_agent/addons/skills/registry.py +109 -0
- coding_agent/addons/skills/tool.py +54 -0
- coding_agent/addons/voice/__init__.py +5 -0
- coding_agent/addons/voice/addon.py +106 -0
- coding_agent/addons/voice/credentials.py +42 -0
- coding_agent/addons/voice/playback.py +42 -0
- coding_agent/addons/voice/speak.py +287 -0
- coding_agent/addons/voice/stream.py +651 -0
- coding_agent/addons/voice/summary.py +126 -0
- coding_agent/agent.py +523 -0
- coding_agent/bench/__init__.py +1 -0
- coding_agent/bench/cli.py +200 -0
- coding_agent/bench/patch.py +78 -0
- coding_agent/commands/__init__.py +5 -0
- coding_agent/commands/catalog.py +179 -0
- coding_agent/commands/jev_argument.py +40 -0
- coding_agent/config/__init__.py +51 -0
- coding_agent/config/config.py +376 -0
- coding_agent/config/credentials.py +153 -0
- coding_agent/config/personalities.json +34 -0
- coding_agent/config/personalities.py +170 -0
- coding_agent/config/prompts.py +67 -0
- coding_agent/protocols/__init__.py +5 -0
- coding_agent/protocols/acp/README.md +36 -0
- coding_agent/protocols/acp/__init__.py +27 -0
- coding_agent/protocols/acp/mapping.py +164 -0
- coding_agent/protocols/acp/server.py +261 -0
- coding_agent/protocols/acp/transport.py +72 -0
- coding_agent/protocols/stdio/README.md +36 -0
- coding_agent/protocols/stdio/__init__.py +37 -0
- coding_agent/protocols/stdio/catalog.py +49 -0
- coding_agent/protocols/stdio/commands.py +218 -0
- coding_agent/protocols/stdio/server.py +181 -0
- coding_agent/protocols/stdio/transport.py +71 -0
- coding_agent/py.typed +0 -0
- coding_agent/resources/__init__.py +5 -0
- coding_agent/resources/usage.py +264 -0
- coding_agent/run/__init__.py +1 -0
- coding_agent/run/cli.py +99 -0
- coding_agent/run/detach.py +37 -0
- coding_agent/run/interrupted.py +67 -0
- coding_agent/run/log_sink.py +46 -0
- coding_agent/tools/__init__.py +73 -0
- coding_agent/tools/ask_user.py +61 -0
- coding_agent/tools/base.py +136 -0
- coding_agent/tools/bash.py +288 -0
- coding_agent/tools/bash_jobs.py +116 -0
- coding_agent/tools/generate_image.py +123 -0
- coding_agent/tools/memory.py +100 -0
- coding_agent/tools/patch.py +218 -0
- coding_agent/tools/read_file.py +167 -0
- coding_agent/tools/search.py +433 -0
- coding_agent/tools/web_search.py +151 -0
- coding_agent/tools/write_file.py +40 -0
- coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
- coding_agent/tui/__init__.py +22 -0
- coding_agent/tui/__main__.py +122 -0
- coding_agent/tui/app.py +456 -0
- coding_agent/tui/chrome/__init__.py +33 -0
- coding_agent/tui/chrome/footer.py +274 -0
- coding_agent/tui/chrome/topbar.py +259 -0
- coding_agent/tui/commands/__init__.py +45 -0
- coding_agent/tui/commands/catalog.py +35 -0
- coding_agent/tui/commands/manager.py +557 -0
- coding_agent/tui/composer/__init__.py +18 -0
- coding_agent/tui/composer/input.py +219 -0
- coding_agent/tui/composer/slash_menu.py +341 -0
- coding_agent/tui/composer/surface.py +330 -0
- coding_agent/tui/composer/voice.py +267 -0
- coding_agent/tui/composer/voice_mode.py +491 -0
- coding_agent/tui/runtime/__init__.py +23 -0
- coding_agent/tui/runtime/events.py +728 -0
- coding_agent/tui/runtime/sink.py +115 -0
- coding_agent/tui/runtime/state.py +145 -0
- coding_agent/tui/runtime/subagent.py +531 -0
- coding_agent/tui/runtime/turn.py +206 -0
- coding_agent/tui/screens/__init__.py +54 -0
- coding_agent/tui/screens/ask.py +69 -0
- coding_agent/tui/screens/context.py +195 -0
- coding_agent/tui/screens/dashboard.py +91 -0
- coding_agent/tui/screens/diff.py +302 -0
- coding_agent/tui/screens/extensions.py +68 -0
- coding_agent/tui/screens/file_selector.py +167 -0
- coding_agent/tui/screens/history.py +458 -0
- coding_agent/tui/screens/langfuse.py +142 -0
- coding_agent/tui/screens/learning.py +113 -0
- coding_agent/tui/screens/modal.py +131 -0
- coding_agent/tui/screens/onboard.py +455 -0
- coding_agent/tui/screens/resume.py +163 -0
- coding_agent/tui/theme/__init__.py +49 -0
- coding_agent/tui/theme/load.py +325 -0
- coding_agent/tui/theme/style.py +95 -0
- coding_agent/tui/theme/theme.toml +1687 -0
- coding_agent/tui/tools/__init__.py +53 -0
- coding_agent/tui/tools/activity.py +184 -0
- coding_agent/tui/tools/calls.py +560 -0
- coding_agent/tui/tools/code_view.py +52 -0
- coding_agent/tui/tools/images.py +250 -0
- coding_agent/tui/tools/labels.py +135 -0
- coding_agent/tui/tools/modal.py +137 -0
- coding_agent/tui/tools/partial_args.py +19 -0
- coding_agent/tui/tools/snapshots.py +382 -0
- coding_agent/tui/transcript/__init__.py +36 -0
- coding_agent/tui/transcript/messages.py +408 -0
- coding_agent/tui/transcript/process.py +504 -0
- coding_agent/tui/transcript/surface.py +345 -0
- coding_agent/tui/transcript/thinking_markdown.py +314 -0
- symphony_code-0.0.1.dist-info/METADATA +66 -0
- symphony_code-0.0.1.dist-info/RECORD +169 -0
- symphony_code-0.0.1.dist-info/WHEEL +4 -0
- symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
- 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]
|