superqode 0.1.5__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 (288) hide show
  1. superqode/__init__.py +33 -0
  2. superqode/acp/__init__.py +23 -0
  3. superqode/acp/client.py +913 -0
  4. superqode/acp/permission_screen.py +457 -0
  5. superqode/acp/types.py +480 -0
  6. superqode/acp_discovery.py +856 -0
  7. superqode/agent/__init__.py +22 -0
  8. superqode/agent/edit_strategies.py +334 -0
  9. superqode/agent/loop.py +892 -0
  10. superqode/agent/qe_report_templates.py +39 -0
  11. superqode/agent/system_prompts.py +353 -0
  12. superqode/agent_output.py +721 -0
  13. superqode/agent_stream.py +953 -0
  14. superqode/agents/__init__.py +59 -0
  15. superqode/agents/acp_registry.py +305 -0
  16. superqode/agents/client.py +249 -0
  17. superqode/agents/data/augmentcode.com.toml +51 -0
  18. superqode/agents/data/cagent.dev.toml +51 -0
  19. superqode/agents/data/claude.com.toml +60 -0
  20. superqode/agents/data/codeassistant.dev.toml +51 -0
  21. superqode/agents/data/codex.openai.com.toml +57 -0
  22. superqode/agents/data/fastagent.ai.toml +66 -0
  23. superqode/agents/data/geminicli.com.toml +77 -0
  24. superqode/agents/data/goose.block.xyz.toml +54 -0
  25. superqode/agents/data/junie.jetbrains.com.toml +56 -0
  26. superqode/agents/data/kimi.moonshot.cn.toml +57 -0
  27. superqode/agents/data/llmlingagent.dev.toml +51 -0
  28. superqode/agents/data/molt.bot.toml +49 -0
  29. superqode/agents/data/opencode.ai.toml +60 -0
  30. superqode/agents/data/stakpak.dev.toml +51 -0
  31. superqode/agents/data/vtcode.dev.toml +51 -0
  32. superqode/agents/discovery.py +266 -0
  33. superqode/agents/messaging.py +160 -0
  34. superqode/agents/persona.py +166 -0
  35. superqode/agents/registry.py +421 -0
  36. superqode/agents/schema.py +72 -0
  37. superqode/agents/unified.py +367 -0
  38. superqode/app/__init__.py +111 -0
  39. superqode/app/constants.py +314 -0
  40. superqode/app/css.py +366 -0
  41. superqode/app/models.py +118 -0
  42. superqode/app/suggester.py +125 -0
  43. superqode/app/widgets.py +1591 -0
  44. superqode/app_enhanced.py +399 -0
  45. superqode/app_main.py +17187 -0
  46. superqode/approval.py +312 -0
  47. superqode/atomic.py +296 -0
  48. superqode/commands/__init__.py +1 -0
  49. superqode/commands/acp.py +965 -0
  50. superqode/commands/agents.py +180 -0
  51. superqode/commands/auth.py +278 -0
  52. superqode/commands/config.py +374 -0
  53. superqode/commands/init.py +826 -0
  54. superqode/commands/providers.py +819 -0
  55. superqode/commands/qe.py +1145 -0
  56. superqode/commands/roles.py +380 -0
  57. superqode/commands/serve.py +172 -0
  58. superqode/commands/suggestions.py +127 -0
  59. superqode/commands/superqe.py +460 -0
  60. superqode/config/__init__.py +51 -0
  61. superqode/config/loader.py +812 -0
  62. superqode/config/schema.py +498 -0
  63. superqode/core/__init__.py +111 -0
  64. superqode/core/roles.py +281 -0
  65. superqode/danger.py +386 -0
  66. superqode/data/superqode-template.yaml +1522 -0
  67. superqode/design_system.py +1080 -0
  68. superqode/dialogs/__init__.py +6 -0
  69. superqode/dialogs/base.py +39 -0
  70. superqode/dialogs/model.py +130 -0
  71. superqode/dialogs/provider.py +870 -0
  72. superqode/diff_view.py +919 -0
  73. superqode/enterprise.py +21 -0
  74. superqode/evaluation/__init__.py +25 -0
  75. superqode/evaluation/adapters.py +93 -0
  76. superqode/evaluation/behaviors.py +89 -0
  77. superqode/evaluation/engine.py +209 -0
  78. superqode/evaluation/scenarios.py +96 -0
  79. superqode/execution/__init__.py +36 -0
  80. superqode/execution/linter.py +538 -0
  81. superqode/execution/modes.py +347 -0
  82. superqode/execution/resolver.py +283 -0
  83. superqode/execution/runner.py +642 -0
  84. superqode/file_explorer.py +811 -0
  85. superqode/file_viewer.py +471 -0
  86. superqode/flash.py +183 -0
  87. superqode/guidance/__init__.py +58 -0
  88. superqode/guidance/config.py +203 -0
  89. superqode/guidance/prompts.py +71 -0
  90. superqode/harness/__init__.py +54 -0
  91. superqode/harness/accelerator.py +291 -0
  92. superqode/harness/config.py +319 -0
  93. superqode/harness/validator.py +147 -0
  94. superqode/history.py +279 -0
  95. superqode/integrations/superopt_runner.py +124 -0
  96. superqode/logging/__init__.py +49 -0
  97. superqode/logging/adapters.py +219 -0
  98. superqode/logging/formatter.py +923 -0
  99. superqode/logging/integration.py +341 -0
  100. superqode/logging/sinks.py +170 -0
  101. superqode/logging/unified_log.py +417 -0
  102. superqode/lsp/__init__.py +26 -0
  103. superqode/lsp/client.py +544 -0
  104. superqode/main.py +1069 -0
  105. superqode/mcp/__init__.py +89 -0
  106. superqode/mcp/auth_storage.py +380 -0
  107. superqode/mcp/client.py +1236 -0
  108. superqode/mcp/config.py +319 -0
  109. superqode/mcp/integration.py +337 -0
  110. superqode/mcp/oauth.py +436 -0
  111. superqode/mcp/oauth_callback.py +385 -0
  112. superqode/mcp/types.py +290 -0
  113. superqode/memory/__init__.py +31 -0
  114. superqode/memory/feedback.py +342 -0
  115. superqode/memory/store.py +522 -0
  116. superqode/notifications.py +369 -0
  117. superqode/optimization/__init__.py +5 -0
  118. superqode/optimization/config.py +33 -0
  119. superqode/permissions/__init__.py +25 -0
  120. superqode/permissions/rules.py +488 -0
  121. superqode/plan.py +323 -0
  122. superqode/providers/__init__.py +33 -0
  123. superqode/providers/gateway/__init__.py +165 -0
  124. superqode/providers/gateway/base.py +228 -0
  125. superqode/providers/gateway/litellm_gateway.py +1170 -0
  126. superqode/providers/gateway/openresponses_gateway.py +436 -0
  127. superqode/providers/health.py +297 -0
  128. superqode/providers/huggingface/__init__.py +74 -0
  129. superqode/providers/huggingface/downloader.py +472 -0
  130. superqode/providers/huggingface/endpoints.py +442 -0
  131. superqode/providers/huggingface/hub.py +531 -0
  132. superqode/providers/huggingface/inference.py +394 -0
  133. superqode/providers/huggingface/transformers_runner.py +516 -0
  134. superqode/providers/local/__init__.py +100 -0
  135. superqode/providers/local/base.py +438 -0
  136. superqode/providers/local/discovery.py +418 -0
  137. superqode/providers/local/lmstudio.py +256 -0
  138. superqode/providers/local/mlx.py +457 -0
  139. superqode/providers/local/ollama.py +486 -0
  140. superqode/providers/local/sglang.py +268 -0
  141. superqode/providers/local/tgi.py +260 -0
  142. superqode/providers/local/tool_support.py +477 -0
  143. superqode/providers/local/vllm.py +258 -0
  144. superqode/providers/manager.py +1338 -0
  145. superqode/providers/models.py +1016 -0
  146. superqode/providers/models_dev.py +578 -0
  147. superqode/providers/openresponses/__init__.py +87 -0
  148. superqode/providers/openresponses/converters/__init__.py +17 -0
  149. superqode/providers/openresponses/converters/messages.py +343 -0
  150. superqode/providers/openresponses/converters/tools.py +268 -0
  151. superqode/providers/openresponses/schema/__init__.py +56 -0
  152. superqode/providers/openresponses/schema/models.py +585 -0
  153. superqode/providers/openresponses/streaming/__init__.py +5 -0
  154. superqode/providers/openresponses/streaming/parser.py +338 -0
  155. superqode/providers/openresponses/tools/__init__.py +21 -0
  156. superqode/providers/openresponses/tools/apply_patch.py +352 -0
  157. superqode/providers/openresponses/tools/code_interpreter.py +290 -0
  158. superqode/providers/openresponses/tools/file_search.py +333 -0
  159. superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
  160. superqode/providers/registry.py +716 -0
  161. superqode/providers/usage.py +332 -0
  162. superqode/pure_mode.py +384 -0
  163. superqode/qr/__init__.py +23 -0
  164. superqode/qr/dashboard.py +781 -0
  165. superqode/qr/generator.py +1018 -0
  166. superqode/qr/templates.py +135 -0
  167. superqode/safety/__init__.py +41 -0
  168. superqode/safety/sandbox.py +413 -0
  169. superqode/safety/warnings.py +256 -0
  170. superqode/server/__init__.py +33 -0
  171. superqode/server/lsp_server.py +775 -0
  172. superqode/server/web.py +250 -0
  173. superqode/session/__init__.py +25 -0
  174. superqode/session/persistence.py +580 -0
  175. superqode/session/sharing.py +477 -0
  176. superqode/session.py +475 -0
  177. superqode/sidebar.py +2991 -0
  178. superqode/stream_view.py +648 -0
  179. superqode/styles/__init__.py +3 -0
  180. superqode/superqe/__init__.py +184 -0
  181. superqode/superqe/acp_runner.py +1064 -0
  182. superqode/superqe/constitution/__init__.py +62 -0
  183. superqode/superqe/constitution/evaluator.py +308 -0
  184. superqode/superqe/constitution/loader.py +432 -0
  185. superqode/superqe/constitution/schema.py +250 -0
  186. superqode/superqe/events.py +591 -0
  187. superqode/superqe/frameworks/__init__.py +65 -0
  188. superqode/superqe/frameworks/base.py +234 -0
  189. superqode/superqe/frameworks/e2e.py +263 -0
  190. superqode/superqe/frameworks/executor.py +237 -0
  191. superqode/superqe/frameworks/javascript.py +409 -0
  192. superqode/superqe/frameworks/python.py +373 -0
  193. superqode/superqe/frameworks/registry.py +92 -0
  194. superqode/superqe/mcp_tools/__init__.py +47 -0
  195. superqode/superqe/mcp_tools/core_tools.py +418 -0
  196. superqode/superqe/mcp_tools/registry.py +230 -0
  197. superqode/superqe/mcp_tools/testing_tools.py +167 -0
  198. superqode/superqe/noise.py +89 -0
  199. superqode/superqe/orchestrator.py +778 -0
  200. superqode/superqe/roles.py +609 -0
  201. superqode/superqe/session.py +713 -0
  202. superqode/superqe/skills/__init__.py +57 -0
  203. superqode/superqe/skills/base.py +106 -0
  204. superqode/superqe/skills/core_skills.py +899 -0
  205. superqode/superqe/skills/registry.py +90 -0
  206. superqode/superqe/verifier.py +101 -0
  207. superqode/superqe_cli.py +76 -0
  208. superqode/tool_call.py +358 -0
  209. superqode/tools/__init__.py +93 -0
  210. superqode/tools/agent_tools.py +496 -0
  211. superqode/tools/base.py +324 -0
  212. superqode/tools/batch_tool.py +133 -0
  213. superqode/tools/diagnostics.py +311 -0
  214. superqode/tools/edit_tools.py +653 -0
  215. superqode/tools/enhanced_base.py +515 -0
  216. superqode/tools/file_tools.py +269 -0
  217. superqode/tools/file_tracking.py +45 -0
  218. superqode/tools/lsp_tools.py +610 -0
  219. superqode/tools/network_tools.py +350 -0
  220. superqode/tools/permissions.py +400 -0
  221. superqode/tools/question_tool.py +324 -0
  222. superqode/tools/search_tools.py +598 -0
  223. superqode/tools/shell_tools.py +259 -0
  224. superqode/tools/todo_tools.py +121 -0
  225. superqode/tools/validation.py +80 -0
  226. superqode/tools/web_tools.py +639 -0
  227. superqode/tui.py +1152 -0
  228. superqode/tui_integration.py +875 -0
  229. superqode/tui_widgets/__init__.py +27 -0
  230. superqode/tui_widgets/widgets/__init__.py +18 -0
  231. superqode/tui_widgets/widgets/progress.py +185 -0
  232. superqode/tui_widgets/widgets/tool_display.py +188 -0
  233. superqode/undo_manager.py +574 -0
  234. superqode/utils/__init__.py +5 -0
  235. superqode/utils/error_handling.py +323 -0
  236. superqode/utils/fuzzy.py +257 -0
  237. superqode/widgets/__init__.py +477 -0
  238. superqode/widgets/agent_collab.py +390 -0
  239. superqode/widgets/agent_store.py +936 -0
  240. superqode/widgets/agent_switcher.py +395 -0
  241. superqode/widgets/animation_manager.py +284 -0
  242. superqode/widgets/code_context.py +356 -0
  243. superqode/widgets/command_palette.py +412 -0
  244. superqode/widgets/connection_status.py +537 -0
  245. superqode/widgets/conversation_history.py +470 -0
  246. superqode/widgets/diff_indicator.py +155 -0
  247. superqode/widgets/enhanced_status_bar.py +385 -0
  248. superqode/widgets/enhanced_toast.py +476 -0
  249. superqode/widgets/file_browser.py +809 -0
  250. superqode/widgets/file_reference.py +585 -0
  251. superqode/widgets/issue_timeline.py +340 -0
  252. superqode/widgets/leader_key.py +264 -0
  253. superqode/widgets/mode_switcher.py +445 -0
  254. superqode/widgets/model_picker.py +234 -0
  255. superqode/widgets/permission_preview.py +1205 -0
  256. superqode/widgets/prompt.py +358 -0
  257. superqode/widgets/provider_connect.py +725 -0
  258. superqode/widgets/pty_shell.py +587 -0
  259. superqode/widgets/qe_dashboard.py +321 -0
  260. superqode/widgets/resizable_sidebar.py +377 -0
  261. superqode/widgets/response_changes.py +218 -0
  262. superqode/widgets/response_display.py +528 -0
  263. superqode/widgets/rich_tool_display.py +613 -0
  264. superqode/widgets/sidebar_panels.py +1180 -0
  265. superqode/widgets/slash_complete.py +356 -0
  266. superqode/widgets/split_view.py +612 -0
  267. superqode/widgets/status_bar.py +273 -0
  268. superqode/widgets/superqode_display.py +786 -0
  269. superqode/widgets/thinking_display.py +815 -0
  270. superqode/widgets/throbber.py +87 -0
  271. superqode/widgets/toast.py +206 -0
  272. superqode/widgets/unified_output.py +1073 -0
  273. superqode/workspace/__init__.py +75 -0
  274. superqode/workspace/artifacts.py +472 -0
  275. superqode/workspace/coordinator.py +353 -0
  276. superqode/workspace/diff_tracker.py +429 -0
  277. superqode/workspace/git_guard.py +373 -0
  278. superqode/workspace/git_snapshot.py +526 -0
  279. superqode/workspace/manager.py +750 -0
  280. superqode/workspace/snapshot.py +357 -0
  281. superqode/workspace/watcher.py +535 -0
  282. superqode/workspace/worktree.py +440 -0
  283. superqode-0.1.5.dist-info/METADATA +204 -0
  284. superqode-0.1.5.dist-info/RECORD +288 -0
  285. superqode-0.1.5.dist-info/WHEEL +5 -0
  286. superqode-0.1.5.dist-info/entry_points.txt +3 -0
  287. superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
  288. superqode-0.1.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,395 @@
1
+ """Agent switcher modal widget (Ctrl+A) - Redesigned for accessibility."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import uuid
7
+
8
+ from textual import on
9
+ from textual.app import ComposeResult
10
+ from textual.containers import Vertical, VerticalScroll, Horizontal
11
+ from textual.message import Message
12
+ from textual.reactive import reactive
13
+ from textual.widget import Widget
14
+ from textual.widgets import Static
15
+
16
+
17
+ @dataclass
18
+ class AgentInfo:
19
+ """Information about an agent."""
20
+
21
+ identity: str
22
+ name: str
23
+ short_name: str
24
+ description: str
25
+ installed: bool = False
26
+ connected: bool = False
27
+ agent_type: str = "coding"
28
+ provider: str = "" # e.g., "OpenCode", "Gemini", "OpenAI"
29
+
30
+
31
+ class AgentItem(Widget):
32
+ """A single agent item in the switcher - high contrast design."""
33
+
34
+ DEFAULT_CSS = """
35
+ AgentItem {
36
+ height: auto;
37
+ min-height: 5;
38
+ padding: 1;
39
+ margin: 0 0 1 0;
40
+ background: #1a1a1a;
41
+ border: solid #444444;
42
+ layout: vertical;
43
+ }
44
+
45
+ AgentItem:hover {
46
+ border: solid #00ffff;
47
+ background: #2a2a2a;
48
+ }
49
+
50
+ AgentItem.selected {
51
+ border: double #00ff00;
52
+ background: #002200;
53
+ }
54
+
55
+ AgentItem.connected {
56
+ border: solid #00ff00;
57
+ background: #002200;
58
+ }
59
+
60
+ AgentItem .agent-header {
61
+ height: 1;
62
+ width: 100%;
63
+ }
64
+
65
+ AgentItem .status-icon {
66
+ width: 4;
67
+ color: #ffffff;
68
+ }
69
+
70
+ AgentItem .agent-name {
71
+ text-style: bold;
72
+ color: #ffffff;
73
+ }
74
+
75
+ AgentItem .agent-status-text {
76
+ dock: right;
77
+ color: #00ff00;
78
+ text-style: bold;
79
+ padding-right: 1;
80
+ }
81
+
82
+ AgentItem .agent-provider {
83
+ color: #00ffff;
84
+ text-style: bold;
85
+ padding-left: 4;
86
+ height: 1;
87
+ }
88
+
89
+ AgentItem .agent-description {
90
+ color: #aaaaaa;
91
+ padding-left: 4;
92
+ height: 1;
93
+ }
94
+
95
+ AgentItem .agent-type {
96
+ color: #888888;
97
+ padding-left: 4;
98
+ text-style: italic;
99
+ }
100
+
101
+ AgentItem.not-installed .agent-name {
102
+ color: #888888;
103
+ }
104
+
105
+ AgentItem.not-installed .agent-status-text {
106
+ color: #ffaa00;
107
+ }
108
+ """
109
+
110
+ class Selected(Message):
111
+ """Message sent when agent is selected."""
112
+
113
+ def __init__(self, agent: AgentInfo) -> None:
114
+ self.agent = agent
115
+ super().__init__()
116
+
117
+ selected: reactive[bool] = reactive(False)
118
+
119
+ def __init__(self, agent: AgentInfo, **kwargs) -> None:
120
+ super().__init__(**kwargs)
121
+ self.agent = agent
122
+
123
+ def compose(self) -> ComposeResult:
124
+ # Header row with icon, name, and status
125
+ with Horizontal(classes="agent-header"):
126
+ # Status icon - clear and visible
127
+ if self.agent.connected:
128
+ icon = "🟢"
129
+ status_text = "CONNECTED"
130
+ elif self.agent.installed:
131
+ icon = "✅"
132
+ status_text = "READY"
133
+ else:
134
+ icon = "📦"
135
+ status_text = "INSTALL"
136
+
137
+ yield Static(icon, classes="status-icon")
138
+ yield Static(f"{self.agent.name}", classes="agent-name")
139
+ yield Static(status_text, classes="agent-status-text")
140
+
141
+ # Provider/Coding Agent info
142
+ provider_text = self.agent.provider or self.agent.short_name.upper()
143
+ yield Static(f"🤖 Coding Agent: {provider_text}", classes="agent-provider")
144
+
145
+ # Description
146
+ desc = (
147
+ self.agent.description[:55] + "..."
148
+ if len(self.agent.description) > 55
149
+ else self.agent.description
150
+ )
151
+ yield Static(desc if desc else "No description", classes="agent-description")
152
+
153
+ # Agent type
154
+ yield Static(f"Type: {self.agent.agent_type}", classes="agent-type")
155
+
156
+ def on_mount(self) -> None:
157
+ """Set classes on mount."""
158
+ self.set_class(self.agent.connected, "connected")
159
+ self.set_class(not self.agent.installed, "not-installed")
160
+
161
+ def watch_selected(self, selected: bool) -> None:
162
+ self.set_class(selected, "selected")
163
+
164
+ def on_click(self) -> None:
165
+ self.post_message(self.Selected(self.agent))
166
+
167
+
168
+ class AgentSwitcher(Widget):
169
+ """
170
+ Quick agent switcher modal (Ctrl+A) - High contrast, accessible design.
171
+
172
+ Shows available agents with their status and allows quick switching.
173
+ """
174
+
175
+ DEFAULT_CSS = """
176
+ AgentSwitcher {
177
+ layer: overlay;
178
+ align: center middle;
179
+ width: 75;
180
+ height: auto;
181
+ max-height: 28;
182
+ background: #000000;
183
+ border: double #00ffff;
184
+ display: none;
185
+ }
186
+
187
+ AgentSwitcher.visible {
188
+ display: block;
189
+ }
190
+
191
+ AgentSwitcher #switcher-title-bar {
192
+ height: 3;
193
+ background: #001a33;
194
+ padding: 1;
195
+ }
196
+
197
+ AgentSwitcher #switcher-title {
198
+ text-style: bold;
199
+ color: #00ffff;
200
+ text-align: center;
201
+ }
202
+
203
+ AgentSwitcher #switcher-subtitle {
204
+ color: #888888;
205
+ text-align: center;
206
+ }
207
+
208
+ AgentSwitcher #agent-list {
209
+ height: auto;
210
+ max-height: 21;
211
+ padding: 1;
212
+ background: #0a0a0a;
213
+ }
214
+
215
+ AgentSwitcher #switcher-footer {
216
+ height: 2;
217
+ background: #1a1a1a;
218
+ color: #00ffff;
219
+ padding: 0 1;
220
+ border-top: solid #333333;
221
+ }
222
+
223
+ AgentSwitcher #footer-hints {
224
+ text-align: center;
225
+ color: #00ff00;
226
+ }
227
+
228
+ AgentSwitcher .empty-message {
229
+ padding: 2;
230
+ color: #ffff00;
231
+ text-style: bold;
232
+ text-align: center;
233
+ background: #1a1a00;
234
+ border: solid #ffff00;
235
+ margin: 1;
236
+ }
237
+
238
+ AgentSwitcher .agent-count {
239
+ dock: right;
240
+ color: #00ff00;
241
+ }
242
+ """
243
+
244
+ class AgentSelected(Message):
245
+ """Message sent when an agent is selected."""
246
+
247
+ def __init__(self, agent: AgentInfo) -> None:
248
+ self.agent = agent
249
+ super().__init__()
250
+
251
+ class Dismissed(Message):
252
+ """Message sent when switcher is dismissed."""
253
+
254
+ # State
255
+ is_visible: reactive[bool] = reactive(False)
256
+ selected_index: reactive[int] = reactive(0)
257
+
258
+ def __init__(self, agents: list[AgentInfo] | None = None, **kwargs) -> None:
259
+ super().__init__(**kwargs)
260
+ self.agents: list[AgentInfo] = agents or []
261
+ self._render_id = "" # Unique ID for each render
262
+
263
+ def compose(self) -> ComposeResult:
264
+ with Vertical(id="switcher-title-bar"):
265
+ yield Static("🤖 AGENT SWITCHER", id="switcher-title")
266
+ yield Static("Select a coding agent to connect", id="switcher-subtitle")
267
+ yield VerticalScroll(id="agent-list")
268
+ with Vertical(id="switcher-footer"):
269
+ yield Static("↑↓ Navigate │ Enter Connect │ Esc Close", id="footer-hints")
270
+
271
+ def show(self, agents: list[AgentInfo] | None = None) -> None:
272
+ """Show agent switcher."""
273
+ if agents is not None:
274
+ self.agents = agents
275
+ self.selected_index = 0
276
+ self.is_visible = True
277
+ self.add_class("visible")
278
+ self._render_agents()
279
+ self.focus()
280
+
281
+ def hide(self) -> None:
282
+ """Hide agent switcher."""
283
+ self.is_visible = False
284
+ self.remove_class("visible")
285
+ self.post_message(self.Dismissed())
286
+
287
+ def toggle(self, agents: list[AgentInfo] | None = None) -> None:
288
+ """Toggle visibility."""
289
+ if self.is_visible:
290
+ self.hide()
291
+ else:
292
+ self.show(agents)
293
+
294
+ def _render_agents(self) -> None:
295
+ """Render the agent list with clear visibility."""
296
+ # Generate unique ID for this render to avoid duplicate widget IDs
297
+ self._render_id = uuid.uuid4().hex[:8]
298
+
299
+ container = self.query_one("#agent-list", VerticalScroll)
300
+ container.remove_children()
301
+
302
+ if not self.agents:
303
+ container.mount(
304
+ Static(
305
+ "No agents found!\nUse /store to browse and install agents.",
306
+ classes="empty-message",
307
+ )
308
+ )
309
+ return
310
+
311
+ # Sort: connected first, then installed, then others
312
+ sorted_agents = sorted(
313
+ self.agents,
314
+ key=lambda a: (not a.connected, not a.installed, a.name),
315
+ )
316
+
317
+ for i, agent in enumerate(sorted_agents):
318
+ # Use unique render_id + index to ensure unique widget IDs
319
+ item = AgentItem(agent, id=f"agent-{self._render_id}-{i}")
320
+ item.selected = i == self.selected_index
321
+ container.mount(item)
322
+
323
+ # Update title to show count
324
+ title = self.query_one("#switcher-title", Static)
325
+ installed = sum(1 for a in self.agents if a.installed)
326
+ connected = sum(1 for a in self.agents if a.connected)
327
+ title.update(f"🤖 CODING AGENTS ({installed} installed, {connected} connected)")
328
+
329
+ def _update_selection(self) -> None:
330
+ """Update visual selection state."""
331
+ items = list(self.query("#agent-list AgentItem"))
332
+ for i, item in enumerate(items):
333
+ if isinstance(item, AgentItem):
334
+ item.selected = i == self.selected_index
335
+
336
+ def move_selection(self, delta: int) -> None:
337
+ """Move selection up or down."""
338
+ if not self.agents:
339
+ return
340
+ new_index = (self.selected_index + delta) % len(self.agents)
341
+ self.selected_index = new_index
342
+ self._update_selection()
343
+
344
+ # Scroll to make selection visible
345
+ try:
346
+ container = self.query_one("#agent-list", VerticalScroll)
347
+ items = list(self.query("#agent-list AgentItem"))
348
+ if 0 <= self.selected_index < len(items):
349
+ container.scroll_visible(items[self.selected_index])
350
+ except Exception:
351
+ pass
352
+
353
+ def select_current(self) -> AgentInfo | None:
354
+ """Select the current agent."""
355
+ if self.agents and 0 <= self.selected_index < len(self.agents):
356
+ # Get sorted agents (same order as rendered)
357
+ sorted_agents = sorted(
358
+ self.agents,
359
+ key=lambda a: (not a.connected, not a.installed, a.name),
360
+ )
361
+ agent = sorted_agents[self.selected_index]
362
+ self.post_message(self.AgentSelected(agent))
363
+ self.hide()
364
+ return agent
365
+ return None
366
+
367
+ def on_key(self, event) -> None:
368
+ """Handle key events."""
369
+ if not self.is_visible:
370
+ return
371
+
372
+ if event.key == "escape":
373
+ self.hide()
374
+ event.stop()
375
+ elif event.key == "up":
376
+ self.move_selection(-1)
377
+ event.stop()
378
+ elif event.key == "down":
379
+ self.move_selection(1)
380
+ event.stop()
381
+ elif event.key == "enter":
382
+ self.select_current()
383
+ event.stop()
384
+
385
+ @on(AgentItem.Selected)
386
+ def on_agent_item_selected(self, event: AgentItem.Selected) -> None:
387
+ """Handle agent selection via click."""
388
+ self.post_message(self.AgentSelected(event.agent))
389
+ self.hide()
390
+
391
+ def update_agents(self, agents: list[AgentInfo]) -> None:
392
+ """Update the agent list."""
393
+ self.agents = agents
394
+ if self.is_visible:
395
+ self._render_agents()
@@ -0,0 +1,284 @@
1
+ """
2
+ Animation Manager - Performance-Conscious Animation Control.
3
+
4
+ Provides centralized control for TUI animations to prevent
5
+ performance issues from multiple concurrent animations.
6
+
7
+ Features:
8
+ - Global frame rate limiting
9
+ - Focus-aware animation pausing
10
+ - Low-power mode support
11
+ - Batched updates for multiple animated widgets
12
+ """
13
+
14
+ import time
15
+ from dataclasses import dataclass, field
16
+ from typing import Callable, Dict, List, Optional, Set
17
+ from weakref import WeakSet
18
+
19
+ from textual.app import App
20
+ from textual.timer import Timer
21
+ from textual.widget import Widget
22
+
23
+
24
+ @dataclass
25
+ class AnimationConfig:
26
+ """Configuration for animation behavior."""
27
+
28
+ max_fps: int = 10 # Maximum frames per second
29
+ pause_on_blur: bool = True # Pause when app loses focus
30
+ low_power_mode: bool = False # Reduce animations for battery
31
+ batch_updates: bool = True # Batch widget updates together
32
+
33
+ @property
34
+ def frame_interval(self) -> float:
35
+ """Get interval between frames in seconds."""
36
+ return 1.0 / self.max_fps
37
+
38
+
39
+ class AnimationManager:
40
+ """Central manager for TUI animations.
41
+
42
+ Controls animation timing across multiple widgets to prevent
43
+ performance degradation from too many concurrent animations.
44
+
45
+ Usage:
46
+ # In your App class
47
+ class MyApp(App):
48
+ def on_mount(self):
49
+ self.animation_manager = AnimationManager(self)
50
+ self.animation_manager.start()
51
+
52
+ # In animated widgets
53
+ class MyWidget(Static):
54
+ def on_mount(self):
55
+ app = self.app
56
+ if hasattr(app, 'animation_manager'):
57
+ app.animation_manager.register(self, self._animate)
58
+
59
+ def _animate(self, frame: int) -> None:
60
+ # Update animation state
61
+ self.refresh()
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ app: App,
67
+ config: Optional[AnimationConfig] = None,
68
+ ):
69
+ self.app = app
70
+ self.config = config or AnimationConfig()
71
+
72
+ # Registered widgets and their callbacks
73
+ self._widgets: WeakSet[Widget] = WeakSet()
74
+ self._callbacks: Dict[int, Callable[[int], None]] = {}
75
+
76
+ # State
77
+ self._timer: Optional[Timer] = None
78
+ self._frame: int = 0
79
+ self._running: bool = False
80
+ self._focused: bool = True
81
+ self._last_tick: float = 0.0
82
+
83
+ # Batching
84
+ self._pending_refreshes: Set[Widget] = set()
85
+
86
+ @property
87
+ def is_running(self) -> bool:
88
+ """Check if animation manager is running."""
89
+ return self._running
90
+
91
+ @property
92
+ def current_frame(self) -> int:
93
+ """Get the current animation frame number."""
94
+ return self._frame
95
+
96
+ @property
97
+ def should_animate(self) -> bool:
98
+ """Check if animations should run right now."""
99
+ if self.config.low_power_mode:
100
+ return False
101
+ if self.config.pause_on_blur and not self._focused:
102
+ return False
103
+ return self._running
104
+
105
+ def register(
106
+ self,
107
+ widget: Widget,
108
+ callback: Callable[[int], None],
109
+ ) -> None:
110
+ """Register a widget for animation updates.
111
+
112
+ Args:
113
+ widget: The widget to animate
114
+ callback: Called each frame with frame number
115
+ """
116
+ self._widgets.add(widget)
117
+ self._callbacks[id(widget)] = callback
118
+
119
+ def unregister(self, widget: Widget) -> None:
120
+ """Unregister a widget from animation updates."""
121
+ self._widgets.discard(widget)
122
+ self._callbacks.pop(id(widget), None)
123
+ self._pending_refreshes.discard(widget)
124
+
125
+ def start(self) -> None:
126
+ """Start the animation manager."""
127
+ if self._running:
128
+ return
129
+
130
+ self._running = True
131
+ self._last_tick = time.monotonic()
132
+
133
+ # Create timer for animation ticks
134
+ self._timer = self.app.set_interval(
135
+ self.config.frame_interval,
136
+ self._tick,
137
+ )
138
+
139
+ def stop(self) -> None:
140
+ """Stop the animation manager."""
141
+ self._running = False
142
+ if self._timer:
143
+ self._timer.stop()
144
+ self._timer = None
145
+
146
+ def pause(self) -> None:
147
+ """Pause animations (e.g., when app loses focus)."""
148
+ self._focused = False
149
+
150
+ def resume(self) -> None:
151
+ """Resume animations (e.g., when app gains focus)."""
152
+ self._focused = True
153
+
154
+ def set_low_power(self, enabled: bool) -> None:
155
+ """Enable/disable low power mode."""
156
+ self.config.low_power_mode = enabled
157
+
158
+ def request_refresh(self, widget: Widget) -> None:
159
+ """Request a refresh for a widget (batched if enabled)."""
160
+ if self.config.batch_updates:
161
+ self._pending_refreshes.add(widget)
162
+ else:
163
+ widget.refresh()
164
+
165
+ def _tick(self) -> None:
166
+ """Called each animation frame."""
167
+ if not self.should_animate:
168
+ return
169
+
170
+ self._frame += 1
171
+ current_time = time.monotonic()
172
+
173
+ # Call all registered callbacks
174
+ dead_widgets = []
175
+ for widget in list(self._widgets):
176
+ if not widget.is_attached:
177
+ dead_widgets.append(widget)
178
+ continue
179
+
180
+ callback = self._callbacks.get(id(widget))
181
+ if callback:
182
+ try:
183
+ callback(self._frame)
184
+ except Exception:
185
+ # Don't let one widget break others
186
+ dead_widgets.append(widget)
187
+
188
+ # Cleanup dead widgets
189
+ for widget in dead_widgets:
190
+ self.unregister(widget)
191
+
192
+ # Flush batched refreshes
193
+ if self.config.batch_updates and self._pending_refreshes:
194
+ for widget in self._pending_refreshes:
195
+ if widget.is_attached:
196
+ widget.refresh()
197
+ self._pending_refreshes.clear()
198
+
199
+ self._last_tick = current_time
200
+
201
+
202
+ class AnimatedWidget(Widget):
203
+ """Base class for widgets that participate in managed animations.
204
+
205
+ Automatically registers with the app's AnimationManager if present.
206
+
207
+ Subclasses should override `on_animation_frame()` to update state.
208
+ """
209
+
210
+ def on_mount(self) -> None:
211
+ """Register with animation manager when mounted."""
212
+ super().on_mount()
213
+ self._register_animation()
214
+
215
+ def on_unmount(self) -> None:
216
+ """Unregister from animation manager when unmounted."""
217
+ self._unregister_animation()
218
+ super().on_unmount()
219
+
220
+ def _register_animation(self) -> None:
221
+ """Register with the app's animation manager."""
222
+ app = self.app
223
+ if hasattr(app, "animation_manager"):
224
+ app.animation_manager.register(self, self.on_animation_frame)
225
+
226
+ def _unregister_animation(self) -> None:
227
+ """Unregister from the app's animation manager."""
228
+ app = self.app
229
+ if hasattr(app, "animation_manager"):
230
+ app.animation_manager.unregister(self)
231
+
232
+ def on_animation_frame(self, frame: int) -> None:
233
+ """Called each animation frame.
234
+
235
+ Override this method to update animation state.
236
+ Call self.refresh() when visual update is needed.
237
+
238
+ Args:
239
+ frame: The current frame number
240
+ """
241
+ pass
242
+
243
+
244
+ class ThrottledRefreshMixin:
245
+ """Mixin that provides throttled refresh capability.
246
+
247
+ Use this to prevent excessive refreshes in widgets that
248
+ receive rapid updates.
249
+
250
+ Usage:
251
+ class MyWidget(ThrottledRefreshMixin, Static):
252
+ def update_content(self, data):
253
+ self._data = data
254
+ self.throttled_refresh()
255
+ """
256
+
257
+ _throttle_interval: float = 0.05 # 50ms minimum between refreshes
258
+ _last_refresh: float = 0.0
259
+ _refresh_pending: bool = False
260
+ _refresh_timer: Optional[Timer] = None
261
+
262
+ def throttled_refresh(self) -> None:
263
+ """Request a throttled refresh."""
264
+ current = time.monotonic()
265
+ elapsed = current - self._last_refresh
266
+
267
+ if elapsed >= self._throttle_interval:
268
+ # Enough time has passed, refresh immediately
269
+ self._last_refresh = current
270
+ self.refresh()
271
+ elif not self._refresh_pending:
272
+ # Schedule a refresh for later
273
+ self._refresh_pending = True
274
+ remaining = self._throttle_interval - elapsed
275
+
276
+ # Use call_later if available (Textual widget)
277
+ if hasattr(self, "call_later"):
278
+ self.call_later(self._do_throttled_refresh, delay=remaining)
279
+
280
+ def _do_throttled_refresh(self) -> None:
281
+ """Execute the pending throttled refresh."""
282
+ self._refresh_pending = False
283
+ self._last_refresh = time.monotonic()
284
+ self.refresh()