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,1073 @@
1
+ """
2
+ Unified Output Display for SuperQode.
3
+
4
+ A single, beautiful output display that works consistently for all modes:
5
+ - BYOK (LiteLLM Gateway)
6
+ - ACP (Agent Client Protocol)
7
+ - Local (Ollama, etc.)
8
+
9
+ Features:
10
+ - Consistent display across all modes
11
+ - Copy to clipboard support (Ctrl+C to copy response)
12
+ - Collapsible thinking section
13
+ - Rich markdown rendering with syntax highlighting
14
+ - Streaming support
15
+ - Clear visual hierarchy
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import re
22
+ import subprocess
23
+ import sys
24
+ from dataclasses import dataclass, field
25
+ from datetime import datetime
26
+ from enum import Enum
27
+ from time import monotonic
28
+ from typing import Any, Callable, Dict, List, Optional, Tuple
29
+
30
+ from rich.console import Group
31
+ from rich.markdown import Markdown
32
+ from rich.panel import Panel
33
+ from rich.syntax import Syntax
34
+ from rich.text import Text
35
+
36
+ from textual.app import ComposeResult
37
+ from textual.binding import Binding
38
+ from textual.containers import Container, Horizontal, ScrollableContainer, Vertical
39
+ from textual.message import Message
40
+ from textual.reactive import reactive
41
+ from textual.timer import Timer
42
+ from textual.widgets import Static
43
+
44
+
45
+ # ============================================================================
46
+ # THEME - Consistent colors across all displays
47
+ # ============================================================================
48
+
49
+
50
+ class Theme:
51
+ """SuperQode unified theme."""
52
+
53
+ # Primary colors
54
+ purple = "#a855f7"
55
+ magenta = "#d946ef"
56
+ pink = "#ec4899"
57
+ cyan = "#06b6d4"
58
+ green = "#22c55e"
59
+ orange = "#f97316"
60
+ gold = "#fbbf24"
61
+ blue = "#3b82f6"
62
+
63
+ # Status colors
64
+ success = "#22c55e"
65
+ error = "#ef4444"
66
+ warning = "#f59e0b"
67
+ info = "#06b6d4"
68
+
69
+ # Text colors
70
+ text = "#e4e4e7"
71
+ text_secondary = "#a1a1aa"
72
+ text_muted = "#71717a"
73
+ text_dim = "#52525b"
74
+
75
+ # Background colors
76
+ bg = "#0a0a0a"
77
+ bg_surface = "#111111"
78
+ bg_elevated = "#1a1a1a"
79
+ bg_thinking = "#0d1117"
80
+ bg_response = "#0f0a1a"
81
+
82
+ # Border colors
83
+ border = "#27272a"
84
+ border_active = "#a855f7"
85
+
86
+
87
+ # Gradient colors for visual interest
88
+ GRADIENT_PURPLE = ["#6d28d9", "#7c3aed", "#8b5cf6", "#a855f7", "#c084fc"]
89
+ GRADIENT_SUCCESS = ["#059669", "#10b981", "#34d399", "#6ee7b7"]
90
+
91
+
92
+ # ============================================================================
93
+ # CLIPBOARD SUPPORT
94
+ # ============================================================================
95
+
96
+
97
+ def copy_to_clipboard(text: str) -> Tuple[bool, str]:
98
+ """
99
+ Copy text to clipboard using OS-native methods.
100
+
101
+ Returns:
102
+ Tuple of (success: bool, message: str)
103
+ """
104
+ try:
105
+ if sys.platform == "darwin":
106
+ # macOS - use pbcopy
107
+ process = subprocess.Popen(
108
+ ["pbcopy"],
109
+ stdin=subprocess.PIPE,
110
+ text=True,
111
+ )
112
+ process.communicate(input=text)
113
+ return (process.returncode == 0, "Copied to clipboard!")
114
+
115
+ elif sys.platform == "linux":
116
+ # Linux - try xclip or xsel
117
+ for cmd in [["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]]:
118
+ try:
119
+ process = subprocess.Popen(
120
+ cmd,
121
+ stdin=subprocess.PIPE,
122
+ text=True,
123
+ )
124
+ process.communicate(input=text)
125
+ if process.returncode == 0:
126
+ return (True, "Copied to clipboard!")
127
+ except FileNotFoundError:
128
+ continue
129
+ return (False, "Install xclip or xsel to copy")
130
+
131
+ elif sys.platform == "win32":
132
+ # Windows - use clip
133
+ process = subprocess.Popen(
134
+ ["clip"],
135
+ stdin=subprocess.PIPE,
136
+ text=True,
137
+ )
138
+ process.communicate(input=text)
139
+ return (process.returncode == 0, "Copied to clipboard!")
140
+
141
+ return (False, "Clipboard not supported on this platform")
142
+
143
+ except Exception as e:
144
+ return (False, f"Copy failed: {str(e)[:30]}")
145
+
146
+
147
+ # ============================================================================
148
+ # DATA CLASSES
149
+ # ============================================================================
150
+
151
+
152
+ class OutputMode(Enum):
153
+ """Connection mode for output."""
154
+
155
+ BYOK = "byok"
156
+ ACP = "acp"
157
+ LOCAL = "local"
158
+
159
+
160
+ class OutputState(Enum):
161
+ """State of the output display."""
162
+
163
+ IDLE = "idle"
164
+ THINKING = "thinking"
165
+ STREAMING = "streaming"
166
+ COMPLETE = "complete"
167
+ ERROR = "error"
168
+
169
+
170
+ @dataclass
171
+ class ThinkingEntry:
172
+ """A single thinking/reasoning entry."""
173
+
174
+ text: str
175
+ category: str = "general"
176
+ timestamp: float = field(default_factory=monotonic)
177
+
178
+ @property
179
+ def icon(self) -> str:
180
+ """Get icon for this thinking category."""
181
+ icons = {
182
+ "planning": "📋",
183
+ "analyzing": "🔬",
184
+ "deciding": "🤔",
185
+ "searching": "🔍",
186
+ "reading": "📖",
187
+ "writing": "✏️",
188
+ "debugging": "🐛",
189
+ "executing": "⚡",
190
+ "verifying": "✅",
191
+ "testing": "🧪",
192
+ "refactoring": "🔧",
193
+ "general": "💭",
194
+ }
195
+ return icons.get(self.category, "💭")
196
+
197
+
198
+ @dataclass
199
+ class OutputStats:
200
+ """Statistics for the output."""
201
+
202
+ mode: OutputMode = OutputMode.BYOK
203
+ agent_name: str = ""
204
+ model_name: str = ""
205
+ start_time: float = 0.0
206
+ end_time: float = 0.0
207
+ thinking_count: int = 0
208
+ tool_count: int = 0
209
+ prompt_tokens: int = 0
210
+ completion_tokens: int = 0
211
+ thinking_tokens: int = 0
212
+ cost: float = 0.0
213
+
214
+ @property
215
+ def duration(self) -> float:
216
+ """Get duration in seconds."""
217
+ if self.end_time > 0 and self.start_time > 0:
218
+ return self.end_time - self.start_time
219
+ elif self.start_time > 0:
220
+ return monotonic() - self.start_time
221
+ return 0.0
222
+
223
+ @property
224
+ def total_tokens(self) -> int:
225
+ """Get total tokens."""
226
+ return self.prompt_tokens + self.completion_tokens
227
+
228
+
229
+ # ============================================================================
230
+ # MESSAGES
231
+ # ============================================================================
232
+
233
+
234
+ class CopyRequested(Message):
235
+ """User requested to copy content."""
236
+
237
+ def __init__(self, content: str) -> None:
238
+ super().__init__()
239
+ self.content = content
240
+
241
+
242
+ class CopyComplete(Message):
243
+ """Copy operation completed."""
244
+
245
+ def __init__(self, success: bool, message: str) -> None:
246
+ super().__init__()
247
+ self.success = success
248
+ self.message = message
249
+
250
+
251
+ # ============================================================================
252
+ # THINKING SECTION WIDGET
253
+ # ============================================================================
254
+
255
+
256
+ class ThinkingSection(Container):
257
+ """
258
+ Collapsible thinking/reasoning section.
259
+
260
+ Shows agent's thought process with:
261
+ - Category icons
262
+ - Animated streaming indicator
263
+ - Collapse/expand toggle
264
+ - Summary when collapsed
265
+ """
266
+
267
+ DEFAULT_CSS = """
268
+ ThinkingSection {
269
+ height: auto;
270
+ max-height: 20;
271
+ background: #0d1117;
272
+ border: round #27272a;
273
+ border-left: tall #ec4899;
274
+ margin: 0 0 1 0;
275
+ padding: 0 1;
276
+ }
277
+
278
+ ThinkingSection.collapsed {
279
+ max-height: 2;
280
+ }
281
+
282
+ ThinkingSection.streaming {
283
+ border: round #fbbf24;
284
+ border-left: tall #fbbf24;
285
+ }
286
+
287
+ ThinkingSection .thinking-header {
288
+ height: 1;
289
+ padding: 0;
290
+ }
291
+
292
+ ThinkingSection .thinking-content {
293
+ height: auto;
294
+ max-height: 18;
295
+ overflow-y: auto;
296
+ }
297
+ """
298
+
299
+ collapsed: reactive[bool] = reactive(True)
300
+ is_streaming: reactive[bool] = reactive(False)
301
+
302
+ def __init__(self, **kwargs):
303
+ super().__init__(**kwargs)
304
+ self._entries: List[ThinkingEntry] = []
305
+ self._current_text = ""
306
+ self._tick = 0
307
+ self._timer: Optional[Timer] = None
308
+
309
+ def on_mount(self) -> None:
310
+ """Start animation timer."""
311
+ self._timer = self.set_interval(0.3, self._animate)
312
+
313
+ def _animate(self) -> None:
314
+ """Animation tick."""
315
+ self._tick += 1
316
+ if self.is_streaming:
317
+ self._update_header()
318
+
319
+ def compose(self) -> ComposeResult:
320
+ yield Static(self._render_header(), classes="thinking-header")
321
+ yield ScrollableContainer(
322
+ Static("", id="thinking-text"),
323
+ classes="thinking-content",
324
+ )
325
+
326
+ def _render_header(self) -> Text:
327
+ """Render the header line."""
328
+ text = Text()
329
+
330
+ # Toggle indicator
331
+ icon = "▾" if not self.collapsed else "▸"
332
+ text.append(f"{icon} ", style=Theme.text_dim)
333
+
334
+ # Thinking icon with animation
335
+ if self.is_streaming:
336
+ frames = ["💭", "💬", "💭", "💬"]
337
+ think_icon = frames[self._tick % len(frames)]
338
+ text.append(f"{think_icon} ", style=f"bold {Theme.gold}")
339
+ text.append("Thinking", style=f"bold {Theme.gold}")
340
+ text.append("...", style=f"bold {Theme.gold}")
341
+ else:
342
+ text.append("💭 ", style=Theme.pink)
343
+ text.append("Thinking", style=Theme.text_secondary)
344
+
345
+ # Count
346
+ if self._entries:
347
+ text.append(f" ({len(self._entries)} thoughts)", style=Theme.text_dim)
348
+
349
+ # Hint
350
+ if self.collapsed and self._entries:
351
+ text.append(" [click to expand]", style=Theme.text_dim)
352
+
353
+ return text
354
+
355
+ def _render_content(self) -> Text:
356
+ """Render the thinking content."""
357
+ if self.collapsed:
358
+ return Text()
359
+
360
+ text = Text()
361
+
362
+ # Show last 10 entries
363
+ visible = self._entries[-10:]
364
+ for entry in visible:
365
+ text.append(f" {entry.icon} ", style=Theme.cyan)
366
+
367
+ # Truncate long entries
368
+ entry_text = entry.text
369
+ if len(entry_text) > 120:
370
+ entry_text = entry_text[:117] + "..."
371
+
372
+ text.append(entry_text, style=f"italic {Theme.text_muted}")
373
+ text.append("\n")
374
+
375
+ # Show current streaming text
376
+ if self._current_text:
377
+ text.append(" ● ", style=f"bold {Theme.gold}")
378
+ current = self._current_text
379
+ if len(current) > 120:
380
+ current = current[:117] + "..."
381
+ text.append(current, style=f"italic {Theme.gold}")
382
+
383
+ return text
384
+
385
+ def _update_header(self) -> None:
386
+ """Update the header."""
387
+ try:
388
+ header = self.query_one(".thinking-header", Static)
389
+ header.update(self._render_header())
390
+ except Exception:
391
+ pass
392
+
393
+ def _update_content(self) -> None:
394
+ """Update the content."""
395
+ try:
396
+ content = self.query_one("#thinking-text", Static)
397
+ content.update(self._render_content())
398
+ except Exception:
399
+ pass
400
+
401
+ def on_click(self) -> None:
402
+ """Toggle on click."""
403
+ self.toggle()
404
+
405
+ def toggle(self) -> None:
406
+ """Toggle collapsed state."""
407
+ self.collapsed = not self.collapsed
408
+ self.set_class(self.collapsed, "collapsed")
409
+ self._update_content()
410
+
411
+ def start_streaming(self) -> None:
412
+ """Start streaming mode."""
413
+ self.is_streaming = True
414
+ self._current_text = ""
415
+ self.collapsed = False
416
+ self.add_class("streaming")
417
+ self.remove_class("collapsed")
418
+ self._update_header()
419
+
420
+ def append_text(self, text: str) -> None:
421
+ """Append text to current streaming thought."""
422
+ self._current_text += text
423
+ self._update_content()
424
+
425
+ def complete_thought(self) -> None:
426
+ """Complete the current thought."""
427
+ if self._current_text:
428
+ category = self._classify_thought(self._current_text)
429
+ entry = ThinkingEntry(
430
+ text=self._current_text.strip(),
431
+ category=category,
432
+ )
433
+ self._entries.append(entry)
434
+ self._current_text = ""
435
+
436
+ self.is_streaming = False
437
+ self.remove_class("streaming")
438
+ self._update_header()
439
+ self._update_content()
440
+
441
+ def add_thought(self, text: str) -> None:
442
+ """Add a complete thought (for ACP mode)."""
443
+ category = self._classify_thought(text)
444
+ entry = ThinkingEntry(text=text.strip(), category=category)
445
+ self._entries.append(entry)
446
+ self._update_header()
447
+ self._update_content()
448
+
449
+ def _classify_thought(self, text: str) -> str:
450
+ """Classify thought by content."""
451
+ text_lower = text.lower()
452
+
453
+ keywords = {
454
+ "testing": ["test", "pytest", "unittest", "expect"],
455
+ "verifying": ["verify", "confirm", "ensure", "check if"],
456
+ "executing": ["run", "execute", "command", "npm", "pip"],
457
+ "refactoring": ["refactor", "restructure", "clean up"],
458
+ "debugging": ["debug", "error", "fix", "bug", "traceback"],
459
+ "planning": ["plan", "step", "approach", "first", "then"],
460
+ "analyzing": ["analyze", "understand", "examine", "review"],
461
+ "deciding": ["decide", "choose", "option", "should"],
462
+ "searching": ["search", "find", "look for", "grep"],
463
+ "reading": ["read", "content", "open", "view"],
464
+ "writing": ["write", "create", "add", "implement"],
465
+ }
466
+
467
+ for category, words in keywords.items():
468
+ if any(w in text_lower for w in words):
469
+ return category
470
+
471
+ return "general"
472
+
473
+ def clear(self) -> None:
474
+ """Clear all thoughts."""
475
+ self._entries.clear()
476
+ self._current_text = ""
477
+ self.is_streaming = False
478
+ self.remove_class("streaming")
479
+ self._update_header()
480
+ self._update_content()
481
+
482
+ @property
483
+ def thought_count(self) -> int:
484
+ """Get number of thoughts."""
485
+ return len(self._entries)
486
+
487
+ def get_all_text(self) -> str:
488
+ """Get all thinking text for copying."""
489
+ lines = []
490
+ for entry in self._entries:
491
+ lines.append(f"{entry.icon} {entry.text}")
492
+ if self._current_text:
493
+ lines.append(f"● {self._current_text}")
494
+ return "\n".join(lines)
495
+
496
+
497
+ # ============================================================================
498
+ # RESPONSE SECTION WIDGET
499
+ # ============================================================================
500
+
501
+
502
+ class ResponseSection(Container):
503
+ """
504
+ Beautiful response display with copy support.
505
+
506
+ Features:
507
+ - Rich markdown rendering
508
+ - Syntax-highlighted code blocks
509
+ - Streaming support with animated cursor
510
+ - Copy to clipboard (Ctrl+C)
511
+ - Stats footer
512
+ """
513
+
514
+ DEFAULT_CSS = """
515
+ ResponseSection {
516
+ height: auto;
517
+ min-height: 5;
518
+ background: #0f0a1a;
519
+ border: round #a855f7;
520
+ padding: 1;
521
+ margin: 0 0 1 0;
522
+ }
523
+
524
+ ResponseSection.streaming {
525
+ border: round #fbbf24;
526
+ }
527
+
528
+ ResponseSection.error {
529
+ border: round #ef4444;
530
+ }
531
+
532
+ ResponseSection .response-header {
533
+ height: 2;
534
+ margin-bottom: 1;
535
+ }
536
+
537
+ ResponseSection .response-content {
538
+ height: auto;
539
+ min-height: 3;
540
+ }
541
+
542
+ ResponseSection .response-footer {
543
+ height: 2;
544
+ margin-top: 1;
545
+ border-top: solid #27272a;
546
+ }
547
+
548
+ ResponseSection .copy-hint {
549
+ height: 1;
550
+ text-align: right;
551
+ }
552
+ """
553
+
554
+ BINDINGS = [
555
+ Binding("ctrl+c", "copy_response", "Copy", show=True, priority=True),
556
+ Binding("c", "copy_response", "Copy", show=False),
557
+ ]
558
+
559
+ is_streaming: reactive[bool] = reactive(False)
560
+ is_error: reactive[bool] = reactive(False)
561
+
562
+ def __init__(self, **kwargs):
563
+ super().__init__(**kwargs)
564
+ self._text = ""
565
+ self._raw_text = "" # Keep raw text for copying
566
+ self._agent_name = ""
567
+ self._model_name = ""
568
+ self._stats: Optional[OutputStats] = None
569
+ self._tick = 0
570
+ self._timer: Optional[Timer] = None
571
+ self._copy_message = ""
572
+ self._copy_message_time = 0.0
573
+
574
+ def on_mount(self) -> None:
575
+ """Start animation timer."""
576
+ self._timer = self.set_interval(0.2, self._animate)
577
+
578
+ def _animate(self) -> None:
579
+ """Animation tick."""
580
+ self._tick += 1
581
+ if self.is_streaming:
582
+ self._update_content()
583
+
584
+ # Clear copy message after 2 seconds
585
+ if self._copy_message and monotonic() - self._copy_message_time > 2:
586
+ self._copy_message = ""
587
+ self._update_footer()
588
+
589
+ def compose(self) -> ComposeResult:
590
+ yield Static(self._render_header(), classes="response-header")
591
+ yield ScrollableContainer(
592
+ Static("", id="response-text"),
593
+ classes="response-content",
594
+ )
595
+ yield Static(self._render_footer(), classes="response-footer")
596
+ yield Static("", classes="copy-hint")
597
+
598
+ def _render_header(self) -> Text:
599
+ """Render response header."""
600
+ text = Text()
601
+
602
+ # Gradient line
603
+ line = "─" * 50
604
+ for i, char in enumerate(line):
605
+ color = GRADIENT_PURPLE[i % len(GRADIENT_PURPLE)]
606
+ text.append(char, style=color)
607
+ text.append("\n")
608
+
609
+ # Agent info
610
+ if self.is_streaming:
611
+ text.append("● ", style=f"bold {Theme.gold}")
612
+ text.append("Generating", style=f"bold {Theme.gold}")
613
+ text.append("...", style=f"bold {Theme.gold}")
614
+ elif self.is_error:
615
+ text.append("✕ ", style=f"bold {Theme.error}")
616
+ text.append("Error", style=f"bold {Theme.error}")
617
+ else:
618
+ text.append("🤖 ", style=Theme.purple)
619
+ if self._agent_name:
620
+ text.append(self._agent_name, style=f"bold {Theme.text}")
621
+ else:
622
+ text.append("Response", style=f"bold {Theme.text}")
623
+
624
+ if self._model_name and not self.is_error:
625
+ text.append(f" [{self._model_name}]", style=Theme.text_dim)
626
+
627
+ return text
628
+
629
+ def _render_content(self) -> Text | Markdown:
630
+ """Render response content."""
631
+ if not self._text:
632
+ return Text("Waiting for response...", style=Theme.text_dim)
633
+
634
+ display_text = self._text
635
+
636
+ # Add streaming cursor
637
+ if self.is_streaming:
638
+ cursors = ["▌", "▐", "▌", " "]
639
+ cursor = cursors[self._tick % len(cursors)]
640
+ display_text += cursor
641
+
642
+ # Try to render as markdown for complete responses
643
+ if not self.is_streaming and not self.is_error:
644
+ try:
645
+ return Markdown(display_text)
646
+ except Exception:
647
+ pass
648
+
649
+ return Text(display_text, style=Theme.text)
650
+
651
+ def _render_footer(self) -> Text:
652
+ """Render stats footer."""
653
+ text = Text()
654
+
655
+ if self._copy_message:
656
+ # Show copy message
657
+ color = Theme.success if "Copied" in self._copy_message else Theme.warning
658
+ text.append(f" {self._copy_message}", style=f"bold {color}")
659
+ return text
660
+
661
+ if not self._stats:
662
+ text.append(" [Ctrl+C to copy]", style=Theme.text_dim)
663
+ return text
664
+
665
+ stats = self._stats
666
+ parts = []
667
+
668
+ # Duration
669
+ if stats.duration > 0:
670
+ parts.append(f"⏱ {stats.duration:.1f}s")
671
+
672
+ # Tokens
673
+ if stats.total_tokens > 0:
674
+ parts.append(f"📊 {stats.total_tokens:,} tokens")
675
+
676
+ # Thinking tokens
677
+ if stats.thinking_tokens > 0:
678
+ parts.append(f"💭 {stats.thinking_tokens:,} thinking")
679
+
680
+ # Cost
681
+ if stats.cost > 0:
682
+ parts.append(f"💰 ${stats.cost:.4f}")
683
+
684
+ # Tools
685
+ if stats.tool_count > 0:
686
+ parts.append(f"🔧 {stats.tool_count} tools")
687
+
688
+ if parts:
689
+ text.append(" " + " │ ".join(parts), style=Theme.text_dim)
690
+
691
+ text.append(" [Ctrl+C to copy]", style=Theme.text_dim)
692
+
693
+ return text
694
+
695
+ def _update_header(self) -> None:
696
+ """Update header."""
697
+ try:
698
+ self.query_one(".response-header", Static).update(self._render_header())
699
+ except Exception:
700
+ pass
701
+
702
+ def _update_content(self) -> None:
703
+ """Update content."""
704
+ try:
705
+ self.query_one("#response-text", Static).update(self._render_content())
706
+ except Exception:
707
+ pass
708
+
709
+ def _update_footer(self) -> None:
710
+ """Update footer."""
711
+ try:
712
+ self.query_one(".response-footer", Static).update(self._render_footer())
713
+ except Exception:
714
+ pass
715
+
716
+ def start_streaming(self, agent_name: str = "", model_name: str = "") -> None:
717
+ """Start streaming mode."""
718
+ self._text = ""
719
+ self._raw_text = ""
720
+ self._agent_name = agent_name
721
+ self._model_name = model_name
722
+ self.is_streaming = True
723
+ self.is_error = False
724
+ self.add_class("streaming")
725
+ self.remove_class("error")
726
+ self._update_header()
727
+ self._update_content()
728
+
729
+ def append_text(self, text: str) -> None:
730
+ """Append text during streaming."""
731
+ self._text += text
732
+ self._raw_text += text
733
+ self._update_content()
734
+
735
+ def set_text(self, text: str) -> None:
736
+ """Set complete text."""
737
+ self._text = text
738
+ self._raw_text = text
739
+ self._update_content()
740
+
741
+ def complete(self, stats: Optional[OutputStats] = None) -> None:
742
+ """Complete the response."""
743
+ self._stats = stats
744
+ self.is_streaming = False
745
+ self.remove_class("streaming")
746
+ self._update_header()
747
+ self._update_content()
748
+ self._update_footer()
749
+
750
+ def set_error(self, error: str) -> None:
751
+ """Set error state."""
752
+ self._text = error
753
+ self._raw_text = error
754
+ self.is_streaming = False
755
+ self.is_error = True
756
+ self.add_class("error")
757
+ self.remove_class("streaming")
758
+ self._update_header()
759
+ self._update_content()
760
+
761
+ def action_copy_response(self) -> None:
762
+ """Copy response to clipboard."""
763
+ if not self._raw_text:
764
+ self._copy_message = "Nothing to copy"
765
+ self._copy_message_time = monotonic()
766
+ self._update_footer()
767
+ return
768
+
769
+ success, message = copy_to_clipboard(self._raw_text)
770
+ self._copy_message = message
771
+ self._copy_message_time = monotonic()
772
+ self._update_footer()
773
+
774
+ # Also post message for parent to handle
775
+ self.post_message(CopyComplete(success, message))
776
+
777
+ def clear(self) -> None:
778
+ """Clear the response."""
779
+ self._text = ""
780
+ self._raw_text = ""
781
+ self._stats = None
782
+ self.is_streaming = False
783
+ self.is_error = False
784
+ self.remove_class("streaming", "error")
785
+ self._update_header()
786
+ self._update_content()
787
+ self._update_footer()
788
+
789
+ def get_text(self) -> str:
790
+ """Get raw text for copying."""
791
+ return self._raw_text
792
+
793
+
794
+ # ============================================================================
795
+ # UNIFIED OUTPUT DISPLAY
796
+ # ============================================================================
797
+
798
+
799
+ class UnifiedOutputDisplay(Container):
800
+ """
801
+ Complete unified output display for all modes.
802
+
803
+ Combines thinking and response sections with:
804
+ - Consistent display across BYOK, ACP, Local
805
+ - Copy support for both thinking and response
806
+ - Clear visual hierarchy
807
+ - Mode indicator
808
+ """
809
+
810
+ DEFAULT_CSS = """
811
+ UnifiedOutputDisplay {
812
+ height: auto;
813
+ padding: 0 1;
814
+ }
815
+
816
+ UnifiedOutputDisplay .output-mode-indicator {
817
+ height: 1;
818
+ text-align: center;
819
+ margin-bottom: 1;
820
+ }
821
+ """
822
+
823
+ BINDINGS = [
824
+ Binding("ctrl+c", "copy_all", "Copy All", show=True),
825
+ Binding("ctrl+shift+c", "copy_response_only", "Copy Response", show=False),
826
+ Binding("ctrl+t", "toggle_thinking", "Toggle Thinking", show=True),
827
+ ]
828
+
829
+ def __init__(self, mode: OutputMode = OutputMode.BYOK, **kwargs):
830
+ super().__init__(**kwargs)
831
+ self._mode = mode
832
+ self._stats = OutputStats(mode=mode)
833
+ self._thinking: Optional[ThinkingSection] = None
834
+ self._response: Optional[ResponseSection] = None
835
+
836
+ def compose(self) -> ComposeResult:
837
+ yield Static(self._render_mode_indicator(), classes="output-mode-indicator")
838
+ self._thinking = ThinkingSection(id="thinking-section")
839
+ yield self._thinking
840
+ self._response = ResponseSection(id="response-section")
841
+ yield self._response
842
+
843
+ def _render_mode_indicator(self) -> Text:
844
+ """Render mode indicator."""
845
+ text = Text()
846
+
847
+ mode_styles = {
848
+ OutputMode.BYOK: ("🔑", "BYOK", Theme.blue),
849
+ OutputMode.ACP: ("🔌", "ACP", Theme.green),
850
+ OutputMode.LOCAL: ("💻", "Local", Theme.orange),
851
+ }
852
+
853
+ icon, label, color = mode_styles.get(self._mode, ("●", "Unknown", Theme.text_dim))
854
+ text.append(f"{icon} ", style=color)
855
+ text.append(label, style=f"bold {color}")
856
+
857
+ if self._stats.agent_name:
858
+ text.append(f" │ {self._stats.agent_name}", style=Theme.text_secondary)
859
+
860
+ if self._stats.model_name:
861
+ text.append(f" → {self._stats.model_name}", style=Theme.text_dim)
862
+
863
+ return text
864
+
865
+ def _update_mode_indicator(self) -> None:
866
+ """Update mode indicator."""
867
+ try:
868
+ self.query_one(".output-mode-indicator", Static).update(self._render_mode_indicator())
869
+ except Exception:
870
+ pass
871
+
872
+ # ========================================================================
873
+ # PUBLIC API - Unified interface for all modes
874
+ # ========================================================================
875
+
876
+ def set_mode(self, mode: OutputMode) -> None:
877
+ """Set the output mode."""
878
+ self._mode = mode
879
+ self._stats.mode = mode
880
+ self._update_mode_indicator()
881
+
882
+ def set_agent_info(self, agent_name: str, model_name: str = "") -> None:
883
+ """Set agent info."""
884
+ self._stats.agent_name = agent_name
885
+ self._stats.model_name = model_name
886
+ self._update_mode_indicator()
887
+
888
+ def start_session(self) -> None:
889
+ """Start a new output session."""
890
+ self._stats = OutputStats(
891
+ mode=self._mode,
892
+ agent_name=self._stats.agent_name,
893
+ model_name=self._stats.model_name,
894
+ start_time=monotonic(),
895
+ )
896
+ if self._thinking:
897
+ self._thinking.clear()
898
+ if self._response:
899
+ self._response.clear()
900
+ self._update_mode_indicator()
901
+
902
+ # ========================================================================
903
+ # THINKING API - Works for all modes
904
+ # ========================================================================
905
+
906
+ def start_thinking(self) -> None:
907
+ """Start thinking display (for streaming modes like BYOK)."""
908
+ if self._thinking:
909
+ self._thinking.start_streaming()
910
+
911
+ def append_thinking(self, text: str) -> None:
912
+ """Append to current thinking (for streaming)."""
913
+ if self._thinking:
914
+ self._thinking.append_text(text)
915
+
916
+ def add_thought(self, text: str) -> None:
917
+ """Add a complete thought (for ACP mode)."""
918
+ if self._thinking:
919
+ self._thinking.add_thought(text)
920
+ self._stats.thinking_count += 1
921
+
922
+ def complete_thinking(self) -> None:
923
+ """Complete current thinking."""
924
+ if self._thinking:
925
+ self._thinking.complete_thought()
926
+ self._stats.thinking_count = self._thinking.thought_count
927
+
928
+ # ========================================================================
929
+ # RESPONSE API - Works for all modes
930
+ # ========================================================================
931
+
932
+ def start_response(self) -> None:
933
+ """Start response streaming."""
934
+ if self._response:
935
+ self._response.start_streaming(
936
+ agent_name=self._stats.agent_name,
937
+ model_name=self._stats.model_name,
938
+ )
939
+
940
+ def append_response(self, text: str) -> None:
941
+ """Append to response."""
942
+ if self._response:
943
+ self._response.append_text(text)
944
+
945
+ def set_response(self, text: str) -> None:
946
+ """Set complete response."""
947
+ if self._response:
948
+ self._response.set_text(text)
949
+
950
+ def complete_response(
951
+ self,
952
+ prompt_tokens: int = 0,
953
+ completion_tokens: int = 0,
954
+ thinking_tokens: int = 0,
955
+ cost: float = 0.0,
956
+ tool_count: int = 0,
957
+ ) -> None:
958
+ """Complete the response with stats."""
959
+ self._stats.end_time = monotonic()
960
+ self._stats.prompt_tokens = prompt_tokens
961
+ self._stats.completion_tokens = completion_tokens
962
+ self._stats.thinking_tokens = thinking_tokens
963
+ self._stats.cost = cost
964
+ self._stats.tool_count = tool_count
965
+
966
+ if self._response:
967
+ self._response.complete(self._stats)
968
+
969
+ def set_error(self, error: str) -> None:
970
+ """Set error state."""
971
+ if self._response:
972
+ self._response.set_error(error)
973
+
974
+ # ========================================================================
975
+ # COPY ACTIONS
976
+ # ========================================================================
977
+
978
+ def action_copy_all(self) -> None:
979
+ """Copy both thinking and response."""
980
+ parts = []
981
+
982
+ if self._thinking:
983
+ thinking_text = self._thinking.get_all_text()
984
+ if thinking_text:
985
+ parts.append("=== THINKING ===\n" + thinking_text)
986
+
987
+ if self._response:
988
+ response_text = self._response.get_text()
989
+ if response_text:
990
+ parts.append("=== RESPONSE ===\n" + response_text)
991
+
992
+ if parts:
993
+ full_text = "\n\n".join(parts)
994
+ success, message = copy_to_clipboard(full_text)
995
+ self.post_message(CopyComplete(success, message))
996
+
997
+ def action_copy_response_only(self) -> None:
998
+ """Copy only the response."""
999
+ if self._response:
1000
+ self._response.action_copy_response()
1001
+
1002
+ def action_toggle_thinking(self) -> None:
1003
+ """Toggle thinking section."""
1004
+ if self._thinking:
1005
+ self._thinking.toggle()
1006
+
1007
+ # ========================================================================
1008
+ # CONVENIENCE HANDLERS FOR DIFFERENT MODES
1009
+ # ========================================================================
1010
+
1011
+ async def handle_byok_chunk(self, chunk: Any) -> None:
1012
+ """
1013
+ Handle BYOK StreamChunk.
1014
+
1015
+ Automatically routes thinking_content and content to correct displays.
1016
+ """
1017
+ # Handle thinking content
1018
+ thinking_content = getattr(chunk, "thinking_content", None)
1019
+ if thinking_content:
1020
+ if self._thinking and not self._thinking.is_streaming:
1021
+ self.start_thinking()
1022
+ self.append_thinking(thinking_content)
1023
+
1024
+ # Handle response content
1025
+ content = getattr(chunk, "content", None)
1026
+ if content:
1027
+ if self._response and not self._response.is_streaming:
1028
+ self.start_response()
1029
+ self.append_response(content)
1030
+
1031
+ async def handle_byok_complete(self, response: Any) -> None:
1032
+ """Handle BYOK GatewayResponse completion."""
1033
+ self.complete_thinking()
1034
+
1035
+ # Extract stats
1036
+ usage = getattr(response, "usage", None)
1037
+ cost = getattr(response, "cost", None)
1038
+
1039
+ self.complete_response(
1040
+ prompt_tokens=getattr(usage, "prompt_tokens", 0) if usage else 0,
1041
+ completion_tokens=getattr(usage, "completion_tokens", 0) if usage else 0,
1042
+ thinking_tokens=getattr(response, "thinking_tokens", 0),
1043
+ cost=getattr(cost, "total", 0.0) if cost else 0.0,
1044
+ )
1045
+
1046
+ async def handle_acp_thought(self, text: str) -> None:
1047
+ """Handle ACP thought chunk (complete thoughts)."""
1048
+ self.add_thought(text)
1049
+
1050
+ async def handle_acp_message(self, text: str) -> None:
1051
+ """Handle ACP message chunk."""
1052
+ if self._response and not self._response.is_streaming:
1053
+ self.start_response()
1054
+ self.append_response(text)
1055
+
1056
+
1057
+ # ============================================================================
1058
+ # EXPORTS
1059
+ # ============================================================================
1060
+
1061
+ __all__ = [
1062
+ "Theme",
1063
+ "OutputMode",
1064
+ "OutputState",
1065
+ "OutputStats",
1066
+ "ThinkingEntry",
1067
+ "ThinkingSection",
1068
+ "ResponseSection",
1069
+ "UnifiedOutputDisplay",
1070
+ "CopyRequested",
1071
+ "CopyComplete",
1072
+ "copy_to_clipboard",
1073
+ ]