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,302 @@
|
|
|
1
|
+
"""Workspace diff modal screen."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
from textual import events
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Container, Horizontal
|
|
13
|
+
from textual.widgets import Static
|
|
14
|
+
from textual.worker import get_current_worker
|
|
15
|
+
|
|
16
|
+
from coding_agent.tui.screens.modal import EmptyState, ModalBase, ModalCloseButton, ModalScroll
|
|
17
|
+
from coding_agent.tui.theme import DIFF_MODAL_CSS
|
|
18
|
+
from coding_agent.tui.tools.calls import diff_stats
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Cards mount in batches so a large diff never holds the UI loop for one
|
|
22
|
+
# long layout pass; the loop paints and takes keys between batches.
|
|
23
|
+
CARD_MOUNT_BATCH = 16
|
|
24
|
+
_HUNK_RE = re.compile(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)")
|
|
25
|
+
# git diff runs in a worker thread; a hung git (lock, network FS) ends here
|
|
26
|
+
# with a message instead of holding the worker forever.
|
|
27
|
+
GIT_DIFF_TIMEOUT_S = 10.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_workspace_diff(workspace: Path) -> str:
|
|
31
|
+
"""Return the current workspace diff, or a user-facing failure message."""
|
|
32
|
+
try:
|
|
33
|
+
result = subprocess.run(
|
|
34
|
+
# Context is intentional: the modal is a code review surface, not just a
|
|
35
|
+
# change counter. It also makes the line-number gutter useful.
|
|
36
|
+
["git", "-C", str(workspace), "diff", "--unified=3", "--", "."],
|
|
37
|
+
check=False,
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
timeout=GIT_DIFF_TIMEOUT_S,
|
|
41
|
+
)
|
|
42
|
+
except Exception as exc: # noqa: BLE001
|
|
43
|
+
return f"Failed to run git diff: {exc}"
|
|
44
|
+
|
|
45
|
+
output = (result.stdout or "").strip()
|
|
46
|
+
if output:
|
|
47
|
+
return output
|
|
48
|
+
if result.returncode != 0:
|
|
49
|
+
err = (result.stderr or "").strip()
|
|
50
|
+
return err or "git diff failed"
|
|
51
|
+
return "No local changes found."
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def split_diff(diff_text: str) -> list[tuple[str, str]]:
|
|
55
|
+
"""Split a Git diff into a path and body for each changed file."""
|
|
56
|
+
files: list[tuple[str, str]] = []
|
|
57
|
+
path = ""
|
|
58
|
+
lines: list[str] = []
|
|
59
|
+
for line in diff_text.splitlines():
|
|
60
|
+
if line.startswith("diff --git "):
|
|
61
|
+
if path:
|
|
62
|
+
files.append((path, "\n".join(lines)))
|
|
63
|
+
paths = line.removeprefix("diff --git ")
|
|
64
|
+
path = paths.split(" b/", 1)[-1].removeprefix("b/")
|
|
65
|
+
lines = []
|
|
66
|
+
elif line.startswith(("index ", "--- ", "+++ ", "new file", "deleted file")):
|
|
67
|
+
continue
|
|
68
|
+
elif path:
|
|
69
|
+
lines.append(line)
|
|
70
|
+
|
|
71
|
+
if path:
|
|
72
|
+
files.append((path, "\n".join(lines)))
|
|
73
|
+
return files
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _numbered_diff(body: str) -> Text:
|
|
77
|
+
"""Render a unified patch with the compact gutter used by the diff modal."""
|
|
78
|
+
# Diff rows must stay on one terminal line. Wrapping long generated source
|
|
79
|
+
# makes continuation rows look like unnumbered diff lines.
|
|
80
|
+
rendered = Text(no_wrap=True, overflow="crop")
|
|
81
|
+
hunks = [match for line in body.splitlines() if (match := _HUNK_RE.match(line))]
|
|
82
|
+
number_width = max(
|
|
83
|
+
2,
|
|
84
|
+
*(len(match.group(1)) for match in hunks),
|
|
85
|
+
*(len(match.group(3)) for match in hunks),
|
|
86
|
+
)
|
|
87
|
+
# A patch can start with a hunk header followed by no context. Keep the
|
|
88
|
+
# gutter stable and reserve one extra column for the change marker.
|
|
89
|
+
old_line = new_line = 0
|
|
90
|
+
|
|
91
|
+
for source in body.splitlines():
|
|
92
|
+
match = _HUNK_RE.match(source)
|
|
93
|
+
if match:
|
|
94
|
+
old_line = int(match.group(1))
|
|
95
|
+
new_line = int(match.group(3))
|
|
96
|
+
# Keep the hunk marker aligned with the source below it. The section
|
|
97
|
+
# name is retained because it is useful when scanning a large file.
|
|
98
|
+
rendered.append(" " * (number_width + 5), style="#687e8b")
|
|
99
|
+
rendered.append(source, style="bold #83a9bd")
|
|
100
|
+
rendered.append("\n")
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
if source.startswith("+"):
|
|
104
|
+
line_number = new_line
|
|
105
|
+
new_line += 1
|
|
106
|
+
marker = "+"
|
|
107
|
+
style = "#a8d58d on #19301d"
|
|
108
|
+
elif source.startswith("-"):
|
|
109
|
+
line_number = old_line
|
|
110
|
+
old_line += 1
|
|
111
|
+
marker = "-"
|
|
112
|
+
style = "#e49a9d on #321c20"
|
|
113
|
+
elif source.startswith(" "):
|
|
114
|
+
line_number = new_line
|
|
115
|
+
old_line += 1
|
|
116
|
+
new_line += 1
|
|
117
|
+
marker = " "
|
|
118
|
+
style = "#b8bec1"
|
|
119
|
+
else:
|
|
120
|
+
line_number = 0
|
|
121
|
+
marker = " "
|
|
122
|
+
style = "#737b7f"
|
|
123
|
+
|
|
124
|
+
number = str(line_number) if line_number else ""
|
|
125
|
+
gutter = f"{number:>{number_width}} {marker} │ "
|
|
126
|
+
content = source[1:] if source[:1] in {"+", "-", " "} else source
|
|
127
|
+
rendered.append(gutter + content, style=style)
|
|
128
|
+
rendered.append("\n")
|
|
129
|
+
|
|
130
|
+
if rendered.plain.endswith("\n"):
|
|
131
|
+
rendered = rendered[:-1]
|
|
132
|
+
return rendered
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class DiffFileCard(Container):
|
|
136
|
+
"""One changed file with its compact header and patch."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, path: str, body: str, *, patch: Text | None = None) -> None:
|
|
139
|
+
self.path = path
|
|
140
|
+
self.body = body
|
|
141
|
+
# ``patch`` is the rendered body when a worker already built it.
|
|
142
|
+
self.patch = patch
|
|
143
|
+
self.additions, self.deletions = diff_stats(body.splitlines())
|
|
144
|
+
super().__init__(classes="diff-file-card")
|
|
145
|
+
|
|
146
|
+
def compose(self): # type: ignore[no-untyped-def]
|
|
147
|
+
stats = Text()
|
|
148
|
+
stats.append(f"+{self.additions}", style="bold #8fc49a")
|
|
149
|
+
stats.append(f" −{self.deletions}", style="bold #df8b91")
|
|
150
|
+
with Horizontal(classes="diff-file-header"):
|
|
151
|
+
yield Static("▾", classes="diff-file-chevron")
|
|
152
|
+
yield Static(Text(self.path, style="bold #d0d0d0"), classes="diff-file-path")
|
|
153
|
+
yield Static(stats, classes="diff-file-stats")
|
|
154
|
+
patch = self.patch if self.patch is not None else _numbered_diff(self.body)
|
|
155
|
+
yield Static(patch, classes="diff-patch", markup=False)
|
|
156
|
+
|
|
157
|
+
def toggle_collapsed(self) -> bool:
|
|
158
|
+
"""Toggle this card and return whether it is now collapsed."""
|
|
159
|
+
collapsed = self.has_class("collapsed")
|
|
160
|
+
if collapsed:
|
|
161
|
+
self.remove_class("collapsed")
|
|
162
|
+
else:
|
|
163
|
+
self.add_class("collapsed")
|
|
164
|
+
self.query_one(".diff-file-chevron", Static).update("▸" if not collapsed else "▾")
|
|
165
|
+
return not collapsed
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class DiffModal(ModalBase[None]):
|
|
169
|
+
"""Fullscreen modal for inspecting one changed file at a time."""
|
|
170
|
+
|
|
171
|
+
CSS = DIFF_MODAL_CSS
|
|
172
|
+
BINDINGS = [
|
|
173
|
+
Binding("escape", "close_modal", "Close", show=False, priority=True),
|
|
174
|
+
Binding("n", "next_file", "Next file", show=False, priority=True),
|
|
175
|
+
Binding("p", "previous_file", "Previous file", show=False, priority=True),
|
|
176
|
+
Binding("enter", "toggle_collapse", "Collapse", show=False, priority=True),
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
def __init__(self, workspace: Path) -> None:
|
|
180
|
+
super().__init__()
|
|
181
|
+
self.workspace = workspace
|
|
182
|
+
self._file_data: list[tuple[str, str]] = []
|
|
183
|
+
self._file_index = 0
|
|
184
|
+
self._cards: list[DiffFileCard] = []
|
|
185
|
+
|
|
186
|
+
def compose(self): # type: ignore[no-untyped-def]
|
|
187
|
+
# git diff can take seconds on a large tree. It runs in a worker after
|
|
188
|
+
# mount; the frame and a loading state paint first.
|
|
189
|
+
with Container(id="diff-pane", classes="modal-pane"):
|
|
190
|
+
# Keep the left side compact, then use a dedicated spacer to pin the
|
|
191
|
+
# file count and close affordance to the right edge.
|
|
192
|
+
yield Horizontal(
|
|
193
|
+
Static("diff", id="diff-title"),
|
|
194
|
+
Static("", id="diff-path"),
|
|
195
|
+
Static(
|
|
196
|
+
Text.assemble(
|
|
197
|
+
("+0", "bold #8fc49a"),
|
|
198
|
+
(" ", "#777777"),
|
|
199
|
+
("−0", "bold #df8b91"),
|
|
200
|
+
),
|
|
201
|
+
id="diff-total-stats",
|
|
202
|
+
),
|
|
203
|
+
Static("", id="diff-header-spacer"),
|
|
204
|
+
Static("loading", id="diff-file-counter"),
|
|
205
|
+
ModalCloseButton("esc close", id="modal-close"),
|
|
206
|
+
id="diff-header",
|
|
207
|
+
)
|
|
208
|
+
with ModalScroll(id="diff-body", classes="modal-body"):
|
|
209
|
+
yield EmptyState("Loading diff", "Running git diff…", id="diff-loading")
|
|
210
|
+
yield Static(
|
|
211
|
+
"↑↓ scroll n next file p previous file enter collapse esc close",
|
|
212
|
+
id="diff-hint",
|
|
213
|
+
classes="modal-footer",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def on_mount(self) -> None:
|
|
217
|
+
self.run_worker(self._read_diff, thread=True, group="workspace-diff", exit_on_error=False)
|
|
218
|
+
|
|
219
|
+
def _read_diff(self) -> None:
|
|
220
|
+
"""Worker thread: run git, split and render the patches, then hand off."""
|
|
221
|
+
diff_text = read_workspace_diff(self.workspace)
|
|
222
|
+
files = split_diff(diff_text)
|
|
223
|
+
patches = [_numbered_diff(file_body) for _path, file_body in files]
|
|
224
|
+
if not get_current_worker().is_cancelled:
|
|
225
|
+
self.app.call_from_thread(self._show_diff, diff_text, files, patches)
|
|
226
|
+
|
|
227
|
+
async def _show_diff(
|
|
228
|
+
self, diff_text: str, files: list[tuple[str, str]], patches: list[Text]
|
|
229
|
+
) -> None:
|
|
230
|
+
if not self.is_attached:
|
|
231
|
+
return # closed while git was running
|
|
232
|
+
self._file_data = files
|
|
233
|
+
body = self.query_one("#diff-body", ModalScroll)
|
|
234
|
+
await self.query_one("#diff-loading", EmptyState).remove()
|
|
235
|
+
if not self._file_data:
|
|
236
|
+
is_clean = diff_text.strip() in {"", "No local changes found."}
|
|
237
|
+
self.query_one("#diff-file-counter", Static).update("0 files")
|
|
238
|
+
await body.mount(EmptyState(
|
|
239
|
+
"Working tree is clean" if is_clean else "Diff unavailable",
|
|
240
|
+
diff_text.strip() or "There are no uncommitted changes to review.",
|
|
241
|
+
))
|
|
242
|
+
return
|
|
243
|
+
self._cards = [
|
|
244
|
+
DiffFileCard(path, file_body, patch=patch)
|
|
245
|
+
for (path, file_body), patch in zip(files, patches)
|
|
246
|
+
]
|
|
247
|
+
for start in range(0, len(self._cards), CARD_MOUNT_BATCH):
|
|
248
|
+
await body.mount_all(self._cards[start:start + CARD_MOUNT_BATCH])
|
|
249
|
+
if start == 0:
|
|
250
|
+
self._show_file(0, scroll=False)
|
|
251
|
+
if not self.is_attached:
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
def on_key(self, event: events.Key) -> None:
|
|
255
|
+
"""Keep modal shortcuts active while the scroll container has focus."""
|
|
256
|
+
actions = {
|
|
257
|
+
"n": self.action_next_file,
|
|
258
|
+
"p": self.action_previous_file,
|
|
259
|
+
"enter": self.action_toggle_collapse,
|
|
260
|
+
}
|
|
261
|
+
action = actions.get(event.key)
|
|
262
|
+
if action is not None:
|
|
263
|
+
event.stop()
|
|
264
|
+
action()
|
|
265
|
+
|
|
266
|
+
def _show_file(self, index: int, *, scroll: bool = True) -> None:
|
|
267
|
+
if not self._cards:
|
|
268
|
+
return
|
|
269
|
+
for card in self._cards:
|
|
270
|
+
card.remove_class("selected")
|
|
271
|
+
card.add_class("inactive")
|
|
272
|
+
self._file_index = index % len(self._cards)
|
|
273
|
+
card = self._cards[self._file_index]
|
|
274
|
+
card.remove_class("inactive")
|
|
275
|
+
card.add_class("selected")
|
|
276
|
+
path, body = self._file_data[self._file_index]
|
|
277
|
+
additions, deletions = diff_stats(body.splitlines())
|
|
278
|
+
self.query_one("#diff-path", Static).update(path)
|
|
279
|
+
self.query_one("#diff-file-counter", Static).update(
|
|
280
|
+
f"{self._file_index + 1} of {len(self._cards)} files"
|
|
281
|
+
)
|
|
282
|
+
self.query_one("#diff-total-stats", Static).update(
|
|
283
|
+
Text.assemble(
|
|
284
|
+
(f"+{additions}", "bold #8fc49a"),
|
|
285
|
+
(" ", "#777777"),
|
|
286
|
+
(f"−{deletions}", "bold #df8b91"),
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
if scroll:
|
|
290
|
+
card.scroll_visible(animate=False, top=True)
|
|
291
|
+
|
|
292
|
+
def action_next_file(self) -> None:
|
|
293
|
+
if self._cards:
|
|
294
|
+
self._show_file(self._file_index + 1)
|
|
295
|
+
|
|
296
|
+
def action_previous_file(self) -> None:
|
|
297
|
+
if self._cards:
|
|
298
|
+
self._show_file(self._file_index - 1)
|
|
299
|
+
|
|
300
|
+
def action_toggle_collapse(self) -> None:
|
|
301
|
+
if self._cards:
|
|
302
|
+
self._cards[self._file_index].toggle_collapsed()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Installed skills and plugins inspector."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
4
|
+
from textual.widgets import Static
|
|
5
|
+
|
|
6
|
+
from coding_agent.addons.skills.args import render_args
|
|
7
|
+
from coding_agent.addons.plugins import LoadedPlugin
|
|
8
|
+
from coding_agent.addons.skills import Skill
|
|
9
|
+
from coding_agent.tui.screens.modal import EmptyState, ModalBase, ModalCloseButton, ModalScroll
|
|
10
|
+
from coding_agent.tui.theme import EXTENSIONS_MODAL_CSS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _skill_card(skill: Skill):
|
|
14
|
+
"""Render one skill as a compact card: name, origin, description, args, and path."""
|
|
15
|
+
with Vertical(classes="extension-card"):
|
|
16
|
+
with Horizontal(classes="extension-card-header"):
|
|
17
|
+
yield Static(skill.name, classes="extension-name")
|
|
18
|
+
yield Static(skill.origin, classes="extension-origin")
|
|
19
|
+
yield Static(skill.description, classes="extension-description", markup=False)
|
|
20
|
+
if skill.args:
|
|
21
|
+
yield Static(f"args {render_args(skill.args)}", classes="extension-path", markup=False)
|
|
22
|
+
yield Static(f"SKILL.md {skill.root / 'SKILL.md'}", classes="extension-path", markup=False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _plugin_card(plugin: LoadedPlugin):
|
|
26
|
+
"""Render one plugin manifest as a card: id, enabled state, description, and root."""
|
|
27
|
+
with Vertical(classes="extension-card"):
|
|
28
|
+
with Horizontal(classes="extension-card-header"):
|
|
29
|
+
yield Static(plugin.plugin_id, classes="extension-name", markup=False)
|
|
30
|
+
yield Static("ENABLED" if plugin.enabled else "DISABLED", classes=(
|
|
31
|
+
"extension-status enabled" if plugin.enabled else "extension-status disabled"
|
|
32
|
+
))
|
|
33
|
+
if plugin.description:
|
|
34
|
+
yield Static(plugin.description, classes="extension-description", markup=False)
|
|
35
|
+
yield Static(str(plugin.root), classes="extension-path", markup=False)
|
|
36
|
+
|
|
37
|
+
class ExtensionsModal(ModalBase[None]):
|
|
38
|
+
"""Show the skills and plugins available to this workspace."""
|
|
39
|
+
CSS = EXTENSIONS_MODAL_CSS
|
|
40
|
+
|
|
41
|
+
def __init__(self, skills: tuple[Skill, ...], plugins: tuple[LoadedPlugin, ...]) -> None:
|
|
42
|
+
super().__init__()
|
|
43
|
+
self.skills, self.plugins = skills, plugins
|
|
44
|
+
|
|
45
|
+
def compose(self):
|
|
46
|
+
total = len(self.skills) + len(self.plugins)
|
|
47
|
+
with Container(id="extensions-pane", classes="modal-pane"):
|
|
48
|
+
with Horizontal(id="extensions-header"):
|
|
49
|
+
yield Static("Installed extensions", id="extensions-title")
|
|
50
|
+
yield Static(f"{total} total", id="extensions-counter")
|
|
51
|
+
yield ModalCloseButton("Esc close", id="modal-close")
|
|
52
|
+
yield Static(
|
|
53
|
+
"Skills and plugins available from your Symphony configuration",
|
|
54
|
+
id="extensions-subtitle",
|
|
55
|
+
)
|
|
56
|
+
with ModalScroll(id="extensions-body", classes="modal-body"):
|
|
57
|
+
if not total:
|
|
58
|
+
yield EmptyState("No extensions installed", "Add skills or plugins to ~/.symphony.")
|
|
59
|
+
else:
|
|
60
|
+
if self.skills:
|
|
61
|
+
yield Static(f"SKILLS · {len(self.skills)}", classes="extensions-section")
|
|
62
|
+
for skill in self.skills:
|
|
63
|
+
yield from _skill_card(skill)
|
|
64
|
+
if self.plugins:
|
|
65
|
+
yield Static(f"PLUGINS · {len(self.plugins)}", classes="extensions-section")
|
|
66
|
+
for plugin in self.plugins:
|
|
67
|
+
yield from _plugin_card(plugin)
|
|
68
|
+
yield Static("↑↓ scroll · Esc close", classes="modal-footer")
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Workspace-file suggestions for ``@path`` composer mentions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from coding_agent.tools.search import DEFAULT_SKIP_DIRS, is_ignored
|
|
12
|
+
|
|
13
|
+
_ACTIVE_MENTION = re.compile(r"(?<!\S)@([^\s@]*)$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class FileOption:
|
|
18
|
+
path: str
|
|
19
|
+
description: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class _IndexedFile:
|
|
24
|
+
path: str
|
|
25
|
+
name: str
|
|
26
|
+
size: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WorkspaceFileIndex:
|
|
30
|
+
"""Walk the workspace once, then filter in memory until invalidated.
|
|
31
|
+
|
|
32
|
+
The TUI calls ``rebuild`` from a worker thread and filters the last
|
|
33
|
+
finished walk with ``cached_matches`` meanwhile, so a large tree never
|
|
34
|
+
walks on the UI thread. ``matches`` stays synchronous for other callers.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, workspace: str | Path) -> None:
|
|
38
|
+
self.workspace = Path(workspace).resolve()
|
|
39
|
+
self._entries: tuple[_IndexedFile, ...] | None = None
|
|
40
|
+
self._fresh = False
|
|
41
|
+
self._generation = 0
|
|
42
|
+
self._lock = threading.Lock()
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def fresh(self) -> bool:
|
|
46
|
+
"""True when the last walk finished after the last ``invalidate``."""
|
|
47
|
+
return self._fresh
|
|
48
|
+
|
|
49
|
+
def invalidate(self) -> None:
|
|
50
|
+
"""Mark the walk stale. The old entries keep serving until a rebuild lands."""
|
|
51
|
+
with self._lock:
|
|
52
|
+
self._generation += 1
|
|
53
|
+
self._fresh = False
|
|
54
|
+
|
|
55
|
+
def rebuild(self) -> bool:
|
|
56
|
+
"""Walk the workspace; safe off the UI thread. False if invalidated meanwhile."""
|
|
57
|
+
with self._lock:
|
|
58
|
+
generation = self._generation
|
|
59
|
+
entries = _scan_workspace(self.workspace)
|
|
60
|
+
with self._lock:
|
|
61
|
+
self._entries = entries
|
|
62
|
+
self._fresh = generation == self._generation
|
|
63
|
+
return self._fresh
|
|
64
|
+
|
|
65
|
+
def matches(self, query: str) -> tuple[FileOption, ...]:
|
|
66
|
+
if not self._fresh:
|
|
67
|
+
self.rebuild()
|
|
68
|
+
return self.cached_matches(query) or ()
|
|
69
|
+
|
|
70
|
+
def cached_matches(self, query: str) -> tuple[FileOption, ...] | None:
|
|
71
|
+
"""Rank the last finished walk, or ``None`` before the first one."""
|
|
72
|
+
entries = self._entries
|
|
73
|
+
if entries is None:
|
|
74
|
+
return None
|
|
75
|
+
return _rank_files(entries, query)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def active_file_mention(value: str) -> tuple[int, str] | None:
|
|
79
|
+
"""Return the active mention's start and query when the cursor is at its end."""
|
|
80
|
+
match = _ACTIVE_MENTION.search(value)
|
|
81
|
+
if match is None:
|
|
82
|
+
return None
|
|
83
|
+
return match.start(), match.group(1)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def complete_file_mention(value: str, path: str) -> tuple[str, int]:
|
|
87
|
+
"""Replace the active ``@query`` while preserving the rest of the prompt."""
|
|
88
|
+
active = active_file_mention(value)
|
|
89
|
+
if active is None:
|
|
90
|
+
completed = f"{value}@{path} "
|
|
91
|
+
return completed, len(completed)
|
|
92
|
+
start, _query = active
|
|
93
|
+
completed = f"{value[:start]}@{path} "
|
|
94
|
+
return completed, len(completed)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def file_matches(
|
|
98
|
+
workspace: Path,
|
|
99
|
+
query: str,
|
|
100
|
+
*,
|
|
101
|
+
index: WorkspaceFileIndex | None = None,
|
|
102
|
+
) -> tuple[FileOption, ...]:
|
|
103
|
+
"""Return gitignore-aware file matches ranked by path relevance."""
|
|
104
|
+
if index is None or index.workspace != Path(workspace).resolve():
|
|
105
|
+
index = WorkspaceFileIndex(workspace)
|
|
106
|
+
return index.matches(query)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _scan_workspace(workspace: Path) -> tuple[_IndexedFile, ...]:
|
|
110
|
+
entries: list[_IndexedFile] = []
|
|
111
|
+
for root, dirs, filenames in os.walk(workspace):
|
|
112
|
+
root_path = Path(root)
|
|
113
|
+
dirs[:] = [
|
|
114
|
+
name
|
|
115
|
+
for name in dirs
|
|
116
|
+
if name not in DEFAULT_SKIP_DIRS
|
|
117
|
+
and not name.startswith(".")
|
|
118
|
+
and not is_ignored(root_path / name, workspace)
|
|
119
|
+
]
|
|
120
|
+
for filename in filenames:
|
|
121
|
+
path = root_path / filename
|
|
122
|
+
if filename.startswith(".") or is_ignored(path, workspace):
|
|
123
|
+
continue
|
|
124
|
+
try:
|
|
125
|
+
size = path.stat().st_size
|
|
126
|
+
except OSError:
|
|
127
|
+
size = 0
|
|
128
|
+
entries.append(
|
|
129
|
+
_IndexedFile(path.relative_to(workspace).as_posix(), filename, size)
|
|
130
|
+
)
|
|
131
|
+
return tuple(entries)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _rank_files(entries: tuple[_IndexedFile, ...], query: str) -> tuple[FileOption, ...]:
|
|
135
|
+
needle = query.strip().lower()
|
|
136
|
+
ranked: list[tuple[tuple[int, int, int, str], FileOption]] = []
|
|
137
|
+
for entry in entries:
|
|
138
|
+
relative_lower = entry.path.lower()
|
|
139
|
+
name_lower = entry.name.lower()
|
|
140
|
+
if needle and needle not in relative_lower:
|
|
141
|
+
continue
|
|
142
|
+
rank = (
|
|
143
|
+
0 if name_lower.startswith(needle) else 1,
|
|
144
|
+
0 if f"/{needle}" in relative_lower else 1,
|
|
145
|
+
len(entry.path),
|
|
146
|
+
relative_lower,
|
|
147
|
+
)
|
|
148
|
+
ranked.append((rank, FileOption(entry.path, _size_label(entry.size))))
|
|
149
|
+
ranked.sort(key=lambda item: item[0])
|
|
150
|
+
return tuple(option for _rank, option in ranked)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _size_label(size: int) -> str:
|
|
154
|
+
if size < 1024:
|
|
155
|
+
return f"{size} B"
|
|
156
|
+
if size < 1024 * 1024:
|
|
157
|
+
return f"{size / 1024:.1f} KB"
|
|
158
|
+
return f"{size / (1024 * 1024):.1f} MB"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
__all__ = [
|
|
162
|
+
"FileOption",
|
|
163
|
+
"WorkspaceFileIndex",
|
|
164
|
+
"active_file_mention",
|
|
165
|
+
"complete_file_mention",
|
|
166
|
+
"file_matches",
|
|
167
|
+
]
|