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,219 @@
1
+ """Prompt editor and composer chrome."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from textual import events
11
+ from textual.containers import Container, Horizontal
12
+ from textual.message import Message
13
+ from textual.widgets import Button, Static, TextArea
14
+
15
+ from coding_agent.tui.screens.modal import ContentModal
16
+ from core_ai.types import Content
17
+ from coding_agent.tui.tools.images import ImageAttachment, ImageModal, dropped_image_paths
18
+ from coding_agent.tui.transcript import UserMessage
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class QueuedTurn:
23
+ """Composer submission: model content, visible text, pastes, and images."""
24
+
25
+ content: Content
26
+ text: str
27
+ pasted_chunks: tuple[str, ...]
28
+ images: tuple[ImageAttachment, ...]
29
+ voice: bool = False
30
+
31
+
32
+ class PromptInput(TextArea):
33
+ """Multiline prompt editor with compact handling for large pastes and images."""
34
+
35
+ class Submitted(Message):
36
+ def __init__(self, text_area: "PromptInput") -> None:
37
+ self.input = text_area
38
+ super().__init__()
39
+
40
+ COMPACT_PASTE_AFTER = UserMessage.COMPACT_PASTE_AFTER
41
+
42
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
43
+ self._pasted_chunks: list[tuple[str, str]] = []
44
+ self._images: list[ImageAttachment] = []
45
+ self._image_seq = 0
46
+ self.submit_on_enter = True
47
+ super().__init__(*args, **kwargs)
48
+
49
+ @property
50
+ def value(self) -> str:
51
+ return self.text
52
+
53
+ @value.setter
54
+ def value(self, value: str) -> None:
55
+ self.load_text(value)
56
+
57
+ @property
58
+ def cursor_position(self) -> int:
59
+ return self.document.get_index_from_location(self.cursor_location)
60
+
61
+ @cursor_position.setter
62
+ def cursor_position(self, value: int) -> None:
63
+ self.cursor_location = self.document.get_location_from_index(value)
64
+
65
+ def action_submit(self) -> None:
66
+ self.post_message(self.Submitted(self))
67
+
68
+ def on_key(self, event: events.Key) -> None:
69
+ """Submit on Enter; preserve Shift+Enter for inserting a newline."""
70
+ if event.key == "shift+enter":
71
+ return
72
+ if event.key != "enter":
73
+ return
74
+ # Voice mode is listening. Enter commits the utterance; it must not
75
+ # submit the partial transcript as a typed turn.
76
+ if getattr(self.app, "_voice_active", False):
77
+ from coding_agent.tui.composer.voice_mode import commit_voice
78
+
79
+ commit_voice(self.app)
80
+ event.prevent_default()
81
+ event.stop()
82
+ return
83
+ from coding_agent.tui.composer.slash_menu import SlashMenu
84
+
85
+ approval_menu = self.app.query_one("#approval-menu", SlashMenu)
86
+ if approval_menu.display and approval_menu.selected_value:
87
+ self.app._choose_menu_option(approval_menu, submit=True)
88
+ else:
89
+ menu = self.app.query_one("#slash-menu", SlashMenu)
90
+ if menu.display and menu.selected_value:
91
+ self.app._choose_menu_option(menu, submit=True)
92
+ elif self.submit_on_enter:
93
+ self.action_submit()
94
+ else:
95
+ return
96
+ event.prevent_default()
97
+ event.stop()
98
+
99
+ @property
100
+ def pasted_chunks(self) -> tuple[str, ...]:
101
+ return tuple(content for _marker, content in self._pasted_chunks)
102
+
103
+ @property
104
+ def images(self) -> tuple[ImageAttachment, ...]:
105
+ return tuple(self._images)
106
+
107
+ def expanded_value(self, value: str | None = None) -> str:
108
+ """Restore compact markers to the clipboard text sent to the agent."""
109
+ expanded = self.value if value is None else value
110
+ for marker, content in self._pasted_chunks:
111
+ expanded = expanded.replace(marker, content, 1)
112
+ return expanded
113
+
114
+ def take_pasted_chunks(self) -> tuple[str, ...]:
115
+ chunks = self.pasted_chunks
116
+ self._pasted_chunks.clear()
117
+ return chunks
118
+
119
+ def take_images(self) -> tuple[ImageAttachment, ...]:
120
+ images = self.images
121
+ self._images.clear()
122
+ self._image_seq = 0
123
+ return images
124
+
125
+ def _insert_paste(self, content: str) -> None:
126
+ if not content:
127
+ return
128
+ paths = dropped_image_paths(content)
129
+ if paths:
130
+ for path in paths:
131
+ self._attach_image(path)
132
+ return
133
+ start, end = self.selection.start, self.selection.end
134
+ should_compact = (
135
+ len(content) > self.COMPACT_PASTE_AFTER or "\n" in content or "\r" in content
136
+ )
137
+ if not should_compact:
138
+ self.replace(content, start, end)
139
+ return
140
+ marker = f"[{len(content):,} chars]"
141
+ self.replace(marker, start, end)
142
+ self._pasted_chunks.append((marker, content))
143
+
144
+ def _attach_image(self, path: Path) -> None:
145
+ self._image_seq += 1
146
+ marker = f"[Image {self._image_seq}]"
147
+ self._images.append(ImageAttachment.from_path(path, marker))
148
+ start, end = self.selection.start, self.selection.end
149
+ index = self.document.get_index_from_location(start)
150
+ prefix = ""
151
+ if index > 0 and self.value[index - 1] not in " \n\t":
152
+ prefix = " "
153
+ self.replace(f"{prefix}{marker} ", start, end)
154
+
155
+ def _on_paste(self, event: Any) -> None:
156
+ self._insert_paste(str(event.text))
157
+ event.prevent_default()
158
+ event.stop()
159
+
160
+ def action_paste(self) -> None:
161
+ self._insert_paste(self.app.clipboard)
162
+
163
+ def on_click(self, event: Any) -> None:
164
+ position = self.cursor_position
165
+ search_from = 0
166
+ for marker, content in self._pasted_chunks:
167
+ start = self.value.find(marker, search_from)
168
+ if start < 0:
169
+ continue
170
+ end = start + len(marker)
171
+ if start <= position <= end:
172
+ self.app.push_screen(ContentModal(content))
173
+ event.stop()
174
+ return
175
+ search_from = end
176
+ search_from = 0
177
+ for image in self._images:
178
+ start = self.value.find(image.marker, search_from)
179
+ if start < 0:
180
+ continue
181
+ end = start + len(image.marker)
182
+ if start <= position <= end:
183
+ self.app.push_screen(ImageModal(image))
184
+ event.stop()
185
+ return
186
+ search_from = end
187
+
188
+
189
+ PROMPT_PLACEHOLDER = "Ask Symphony..."
190
+
191
+
192
+ class QueuedPrompt(Container):
193
+ """Follow-up prompt controls shown on one line inside the composer."""
194
+
195
+ def compose(self): # type: ignore[no-untyped-def]
196
+ yield Static("", id="queued-prompt-text", markup=False)
197
+ yield Button("Send now", id="queued-send-now", variant="default")
198
+ yield Button("Edit", id="queued-edit", variant="default")
199
+
200
+ def refresh_queue(self, queued: Sequence[QueuedTurn]) -> None:
201
+ visible = bool(queued)
202
+ if self.display != visible:
203
+ self.display = visible
204
+ if queued:
205
+ self.query_one("#queued-prompt-text", Static).update(queued[0].text)
206
+
207
+
208
+ class Composer(Container):
209
+ """Borderless prompt under a thin separator, for composing messages."""
210
+
211
+ def compose(self): # type: ignore[no-untyped-def]
212
+ yield QueuedPrompt(id="queued-prompt-row")
213
+ with Horizontal(id="composer-row"):
214
+ yield PromptInput(
215
+ placeholder=PROMPT_PLACEHOLDER,
216
+ id="prompt",
217
+ soft_wrap=True,
218
+ show_line_numbers=False,
219
+ )
@@ -0,0 +1,341 @@
1
+ """Slash-command, file, and question selection menu."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import PurePosixPath
6
+ from typing import Any
7
+
8
+ from rich.text import Text
9
+ from textual import events
10
+ from textual.widgets import OptionList
11
+ from textual.widgets.option_list import Option
12
+
13
+ from coding_agent.commands.catalog import (
14
+ EffortOption,
15
+ ModelOption,
16
+ PersonalityOption,
17
+ SlashCommand,
18
+ )
19
+ from coding_agent.tui.screens.file_selector import FileOption
20
+ from coding_agent.tui.theme import SYMPHONY_COLORS
21
+
22
+
23
+ class SlashMenu(OptionList):
24
+ """Cached completion menu for files and commands."""
25
+
26
+ _files_cache: tuple[FileOption, ...] | None = None
27
+ _matches_cache: dict[tuple[str, str], tuple[Option, ...]] = {}
28
+
29
+ @classmethod
30
+ def clear_session_cache(cls) -> None:
31
+ cls._files_cache = None
32
+ cls._matches_cache.clear()
33
+
34
+ """Discoverable command suggestions displayed above the composer."""
35
+
36
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
37
+ super().__init__(*args, markup=False, **kwargs)
38
+ self.selected_index = 0
39
+ self._option_offset = 0
40
+ self._commands: tuple[SlashCommand, ...] = ()
41
+ self._models: tuple[ModelOption, ...] = ()
42
+ self._personalities: tuple[PersonalityOption, ...] = ()
43
+ self._efforts: tuple[EffortOption, ...] = ()
44
+ self._files: tuple[FileOption, ...] = ()
45
+ self._questions: tuple[str, ...] = ()
46
+ self._question_text = ""
47
+ self._question_kind = ""
48
+ self._current_model = ""
49
+ self._current_personality = ""
50
+ self._current_effort = ""
51
+
52
+ @property
53
+ def selected_value(self) -> str:
54
+ if self._files:
55
+ return f"@{self._files[self.selected_index].path}"
56
+ if self._models:
57
+ return f"/model {self._models[self.selected_index].id}"
58
+ if self._personalities:
59
+ return f"/personality {self._personalities[self.selected_index].id}"
60
+ if self._efforts:
61
+ return f"/effort {self._efforts[self.selected_index].id}"
62
+ if self._questions:
63
+ return self._questions[self.selected_index]
64
+ if self._commands:
65
+ return f"/{self._commands[self.selected_index].name}"
66
+ return ""
67
+
68
+ @property
69
+ def is_file_selector(self) -> bool:
70
+ return bool(self._files)
71
+
72
+ @property
73
+ def _choice_count(self) -> int:
74
+ return (
75
+ len(self._files)
76
+ or len(self._models)
77
+ or len(self._personalities)
78
+ or len(self._efforts)
79
+ or len(self._questions)
80
+ or len(self._commands)
81
+ )
82
+
83
+ def move_selection(self, offset: int) -> None:
84
+ count = self._choice_count
85
+ if not count:
86
+ return
87
+ self.selected_index = (self.selected_index + offset) % count
88
+ self.highlighted = self.selected_index + self._option_offset
89
+
90
+ def _move_pointer_selection(self, offset: int) -> None:
91
+ """Move selection without wrapping at the ends for pointer scrolling."""
92
+ selected_index = min(
93
+ max(0, self.selected_index + offset), self._choice_count - 1
94
+ )
95
+ if selected_index != self.selected_index:
96
+ self.selected_index = selected_index
97
+ self.highlighted = selected_index + self._option_offset
98
+
99
+ def select_option_index(self, option_index: int) -> bool:
100
+ """Synchronize selection from an OptionList pointer event."""
101
+ selected_index = option_index - self._option_offset
102
+ if not 0 <= selected_index < self._choice_count:
103
+ return False
104
+ self.selected_index = selected_index
105
+ self.highlighted = option_index
106
+ return True
107
+
108
+ def on_option_list_option_highlighted(
109
+ self, event: OptionList.OptionHighlighted
110
+ ) -> None:
111
+ """Keep pointer hover and keyboard selection on the same option."""
112
+ selected_index = event.option_index - self._option_offset
113
+ if 0 <= selected_index < self._choice_count:
114
+ self.selected_index = selected_index
115
+
116
+ def on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None:
117
+ if self._choice_count:
118
+ self._move_pointer_selection(1)
119
+ event.prevent_default()
120
+ event.stop()
121
+
122
+ def on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
123
+ if self._choice_count:
124
+ self._move_pointer_selection(-1)
125
+ event.prevent_default()
126
+ event.stop()
127
+
128
+ def _replace_choices(
129
+ self,
130
+ *,
131
+ commands: tuple[SlashCommand, ...] = (),
132
+ models: tuple[ModelOption, ...] = (),
133
+ personalities: tuple[PersonalityOption, ...] = (),
134
+ efforts: tuple[EffortOption, ...] = (),
135
+ files: tuple[FileOption, ...] = (),
136
+ questions: tuple[str, ...] = (),
137
+ question_text: str = "",
138
+ question_kind: str = "",
139
+ selected_index: int = 0,
140
+ ) -> None:
141
+ self._commands = commands
142
+ self._models = models
143
+ self._personalities = personalities
144
+ self._efforts = efforts
145
+ self._files = files
146
+ self._questions = questions
147
+ self._question_text = question_text
148
+ self._question_kind = question_kind
149
+ self.selected_index = selected_index
150
+ self.set_class(question_kind == "approval", "permission-menu")
151
+ self.set_class(bool(files), "file-menu")
152
+
153
+ if self._choice_count or question_text:
154
+ self._render_options()
155
+ elif self.display:
156
+ self._hide()
157
+
158
+ def _hide(self) -> None:
159
+ self._option_offset = 0
160
+ self.clear_options()
161
+ self.remove_class("permission-menu")
162
+ self.remove_class("file-menu")
163
+ self.display = False
164
+
165
+ def set_commands(self, commands: tuple[SlashCommand, ...]) -> None:
166
+ self._replace_choices(commands=commands)
167
+
168
+ def set_models(self, models: tuple[ModelOption, ...], current: str = "") -> None:
169
+ self._current_model = current
170
+ self._replace_choices(models=models)
171
+
172
+ def set_personalities(
173
+ self, personalities: tuple[PersonalityOption, ...], current: str = ""
174
+ ) -> None:
175
+ self._current_personality = current
176
+ self._replace_choices(personalities=personalities)
177
+
178
+ def set_efforts(self, efforts: tuple[EffortOption, ...], current: str = "") -> None:
179
+ self._current_effort = current
180
+ selected_index = next(
181
+ (index for index, effort in enumerate(efforts) if effort.id == current),
182
+ 0,
183
+ )
184
+ self._replace_choices(efforts=efforts, selected_index=selected_index)
185
+
186
+
187
+ def set_files(self, files: tuple[FileOption, ...]) -> None:
188
+ if self._files_cache == files:
189
+ files = self._files_cache
190
+ else:
191
+ self._files_cache = files
192
+ self._replace_choices(files=files)
193
+
194
+ def set_question(
195
+ self,
196
+ question: str,
197
+ choices: list[str],
198
+ *,
199
+ default: str = "",
200
+ kind: str = "",
201
+ ) -> None:
202
+ selected_index = choices.index(default) if default in choices else 0
203
+ self._replace_choices(
204
+ questions=tuple(choices),
205
+ question_text=question,
206
+ question_kind=kind,
207
+ selected_index=selected_index,
208
+ )
209
+
210
+ def _render_options(self) -> None:
211
+ if self._question_text:
212
+ rows, headers, hint = self._question_rows()
213
+ self._set_rendered_options(rows, headers=headers, hint=hint)
214
+ return
215
+
216
+ rows = self._choice_rows()
217
+ if self._files:
218
+ self._set_rendered_options(
219
+ rows,
220
+ headers=[Text(" FILES", style="#666666")],
221
+ hint=" ↑↓ select Tab insert Enter choose",
222
+ )
223
+ return
224
+ self._set_rendered_options(
225
+ rows, hint=" ↑/↓ select · Enter choose · Tab complete"
226
+ )
227
+
228
+ def _question_rows(self) -> tuple[list[Text], list[Text], str]:
229
+ if self._question_kind == "approval":
230
+ rows = [self._approval_choice(choice) for choice in self._questions]
231
+ return rows, self._approval_headers(), " Enter confirm · Esc deny"
232
+
233
+ rows = [Text(f" {choice}", style="#c5c5c5") for choice in self._questions]
234
+ headers = [Text(f"? {self._question_text}", style="bold #d7d7d7")]
235
+ hint = (
236
+ " ↑/↓ select · Enter choose"
237
+ if self._questions
238
+ else " Type an answer · Enter submit"
239
+ )
240
+ return rows, headers, hint
241
+
242
+ @staticmethod
243
+ def _approval_choice(choice: str) -> Text:
244
+ if choice == "Allow once":
245
+ return Text(" ✓ Allow once", style="bold #8fc49a")
246
+ if choice == "Always allow":
247
+ return Text(" ⚡ Always allow", style="bold #e0b66b")
248
+ if choice == "Deny":
249
+ return Text(" × Deny", style="bold #df8b91")
250
+ return Text(f" {choice}", style="#c5c5c5")
251
+
252
+ def _choice_rows(self) -> list[Text]:
253
+ if self._files:
254
+ return [self._file_row(file) for file in self._files]
255
+ if self._models:
256
+ return [self._model_row(model) for model in self._models]
257
+ if self._personalities:
258
+ return [
259
+ self._described_row(
260
+ f"{'●' if item.id == self._current_personality else '○'} {item.id:<27}",
261
+ item.description,
262
+ )
263
+ for item in self._personalities
264
+ ]
265
+ if self._efforts:
266
+ return [
267
+ self._described_row(
268
+ f"{'●' if effort.id == self._current_effort else '○'} {effort.label:<27}",
269
+ effort.description,
270
+ )
271
+ for effort in self._efforts
272
+ ]
273
+ return [
274
+ self._described_row(f"{command.usage:<22}", command.description)
275
+ for command in self._commands
276
+ ]
277
+
278
+ def _model_row(self, model: ModelOption) -> Text:
279
+ """Active model gets a mark. The rest stay plain so the list can scroll."""
280
+ current = model.id == self._current_model
281
+ name = model.label or model.id.split(":", 1)[-1]
282
+ provider = model.id.split(":", 1)[0]
283
+ row = Text(" ▸ " if current else " ", style=f"bold {SYMPHONY_COLORS['accent']}")
284
+ row.append(name, style="bold #ededed" if current else "#d0d0d0")
285
+ row.append(f" {provider}", style="#737373")
286
+ if model.description and model.description != provider:
287
+ detail = model.description.split("·", 1)[-1].strip()
288
+ row.append(f" {detail}", style="#5f5f5f")
289
+ return row
290
+
291
+ @staticmethod
292
+ def _described_row(label: str, description: str) -> Text:
293
+ row = Text(f" {label}", style="bold #c5c5c5")
294
+ row.append(description, style="#858585")
295
+ return row
296
+
297
+ @staticmethod
298
+ def _file_row(file: FileOption) -> Text:
299
+ path = PurePosixPath(file.path)
300
+ parent = "" if str(path.parent) == "." else f"{path.parent}/"
301
+ row = Text(" @ ", style="#7085ba")
302
+ row.append(parent, style="#737373")
303
+ row.append(path.name, style="#d0d0d0")
304
+ occupied = 5 + len(parent) + len(path.name)
305
+ row.append(" " * max(3, 58 - occupied))
306
+ row.append(file.description, style="#5f5f5f")
307
+ return row
308
+
309
+ def _set_rendered_options(
310
+ self,
311
+ rows: list[Text],
312
+ *,
313
+ headers: list[Text] | None = None,
314
+ hint: str,
315
+ ) -> None:
316
+ headers = headers or []
317
+ self._option_offset = len(headers)
318
+ options = [Option(header, disabled=True) for header in headers]
319
+ options.extend(
320
+ Option(row, id=f"choice-{index}") for index, row in enumerate(rows)
321
+ )
322
+ options.append(Option(Text(hint, style="#505050"), disabled=True))
323
+ self.set_options(options)
324
+ if rows:
325
+ self.selected_index = min(self.selected_index, len(rows) - 1)
326
+ self.highlighted = self.selected_index + self._option_offset
327
+ self.display = True
328
+
329
+ def _approval_headers(self) -> list[Text]:
330
+ summary, _, detail = self._question_text.partition("\n")
331
+ action = detail.strip().strip("`") or summary.replace("`", "")
332
+ preview = Text()
333
+ for index, line in enumerate(action.splitlines() or [action]):
334
+ if index:
335
+ preview.append("\n")
336
+ preview.append(" │ ", style="#666666")
337
+ preview.append(line, style="bold #d0d0d0")
338
+ return [
339
+ Text(" Allow Symphony to run the following command?", style="bold #d0d0d0"),
340
+ preview,
341
+ ]