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