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,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
+ ]