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.
- superqode/__init__.py +33 -0
- superqode/acp/__init__.py +23 -0
- superqode/acp/client.py +913 -0
- superqode/acp/permission_screen.py +457 -0
- superqode/acp/types.py +480 -0
- superqode/acp_discovery.py +856 -0
- superqode/agent/__init__.py +22 -0
- superqode/agent/edit_strategies.py +334 -0
- superqode/agent/loop.py +892 -0
- superqode/agent/qe_report_templates.py +39 -0
- superqode/agent/system_prompts.py +353 -0
- superqode/agent_output.py +721 -0
- superqode/agent_stream.py +953 -0
- superqode/agents/__init__.py +59 -0
- superqode/agents/acp_registry.py +305 -0
- superqode/agents/client.py +249 -0
- superqode/agents/data/augmentcode.com.toml +51 -0
- superqode/agents/data/cagent.dev.toml +51 -0
- superqode/agents/data/claude.com.toml +60 -0
- superqode/agents/data/codeassistant.dev.toml +51 -0
- superqode/agents/data/codex.openai.com.toml +57 -0
- superqode/agents/data/fastagent.ai.toml +66 -0
- superqode/agents/data/geminicli.com.toml +77 -0
- superqode/agents/data/goose.block.xyz.toml +54 -0
- superqode/agents/data/junie.jetbrains.com.toml +56 -0
- superqode/agents/data/kimi.moonshot.cn.toml +57 -0
- superqode/agents/data/llmlingagent.dev.toml +51 -0
- superqode/agents/data/molt.bot.toml +49 -0
- superqode/agents/data/opencode.ai.toml +60 -0
- superqode/agents/data/stakpak.dev.toml +51 -0
- superqode/agents/data/vtcode.dev.toml +51 -0
- superqode/agents/discovery.py +266 -0
- superqode/agents/messaging.py +160 -0
- superqode/agents/persona.py +166 -0
- superqode/agents/registry.py +421 -0
- superqode/agents/schema.py +72 -0
- superqode/agents/unified.py +367 -0
- superqode/app/__init__.py +111 -0
- superqode/app/constants.py +314 -0
- superqode/app/css.py +366 -0
- superqode/app/models.py +118 -0
- superqode/app/suggester.py +125 -0
- superqode/app/widgets.py +1591 -0
- superqode/app_enhanced.py +399 -0
- superqode/app_main.py +17187 -0
- superqode/approval.py +312 -0
- superqode/atomic.py +296 -0
- superqode/commands/__init__.py +1 -0
- superqode/commands/acp.py +965 -0
- superqode/commands/agents.py +180 -0
- superqode/commands/auth.py +278 -0
- superqode/commands/config.py +374 -0
- superqode/commands/init.py +826 -0
- superqode/commands/providers.py +819 -0
- superqode/commands/qe.py +1145 -0
- superqode/commands/roles.py +380 -0
- superqode/commands/serve.py +172 -0
- superqode/commands/suggestions.py +127 -0
- superqode/commands/superqe.py +460 -0
- superqode/config/__init__.py +51 -0
- superqode/config/loader.py +812 -0
- superqode/config/schema.py +498 -0
- superqode/core/__init__.py +111 -0
- superqode/core/roles.py +281 -0
- superqode/danger.py +386 -0
- superqode/data/superqode-template.yaml +1522 -0
- superqode/design_system.py +1080 -0
- superqode/dialogs/__init__.py +6 -0
- superqode/dialogs/base.py +39 -0
- superqode/dialogs/model.py +130 -0
- superqode/dialogs/provider.py +870 -0
- superqode/diff_view.py +919 -0
- superqode/enterprise.py +21 -0
- superqode/evaluation/__init__.py +25 -0
- superqode/evaluation/adapters.py +93 -0
- superqode/evaluation/behaviors.py +89 -0
- superqode/evaluation/engine.py +209 -0
- superqode/evaluation/scenarios.py +96 -0
- superqode/execution/__init__.py +36 -0
- superqode/execution/linter.py +538 -0
- superqode/execution/modes.py +347 -0
- superqode/execution/resolver.py +283 -0
- superqode/execution/runner.py +642 -0
- superqode/file_explorer.py +811 -0
- superqode/file_viewer.py +471 -0
- superqode/flash.py +183 -0
- superqode/guidance/__init__.py +58 -0
- superqode/guidance/config.py +203 -0
- superqode/guidance/prompts.py +71 -0
- superqode/harness/__init__.py +54 -0
- superqode/harness/accelerator.py +291 -0
- superqode/harness/config.py +319 -0
- superqode/harness/validator.py +147 -0
- superqode/history.py +279 -0
- superqode/integrations/superopt_runner.py +124 -0
- superqode/logging/__init__.py +49 -0
- superqode/logging/adapters.py +219 -0
- superqode/logging/formatter.py +923 -0
- superqode/logging/integration.py +341 -0
- superqode/logging/sinks.py +170 -0
- superqode/logging/unified_log.py +417 -0
- superqode/lsp/__init__.py +26 -0
- superqode/lsp/client.py +544 -0
- superqode/main.py +1069 -0
- superqode/mcp/__init__.py +89 -0
- superqode/mcp/auth_storage.py +380 -0
- superqode/mcp/client.py +1236 -0
- superqode/mcp/config.py +319 -0
- superqode/mcp/integration.py +337 -0
- superqode/mcp/oauth.py +436 -0
- superqode/mcp/oauth_callback.py +385 -0
- superqode/mcp/types.py +290 -0
- superqode/memory/__init__.py +31 -0
- superqode/memory/feedback.py +342 -0
- superqode/memory/store.py +522 -0
- superqode/notifications.py +369 -0
- superqode/optimization/__init__.py +5 -0
- superqode/optimization/config.py +33 -0
- superqode/permissions/__init__.py +25 -0
- superqode/permissions/rules.py +488 -0
- superqode/plan.py +323 -0
- superqode/providers/__init__.py +33 -0
- superqode/providers/gateway/__init__.py +165 -0
- superqode/providers/gateway/base.py +228 -0
- superqode/providers/gateway/litellm_gateway.py +1170 -0
- superqode/providers/gateway/openresponses_gateway.py +436 -0
- superqode/providers/health.py +297 -0
- superqode/providers/huggingface/__init__.py +74 -0
- superqode/providers/huggingface/downloader.py +472 -0
- superqode/providers/huggingface/endpoints.py +442 -0
- superqode/providers/huggingface/hub.py +531 -0
- superqode/providers/huggingface/inference.py +394 -0
- superqode/providers/huggingface/transformers_runner.py +516 -0
- superqode/providers/local/__init__.py +100 -0
- superqode/providers/local/base.py +438 -0
- superqode/providers/local/discovery.py +418 -0
- superqode/providers/local/lmstudio.py +256 -0
- superqode/providers/local/mlx.py +457 -0
- superqode/providers/local/ollama.py +486 -0
- superqode/providers/local/sglang.py +268 -0
- superqode/providers/local/tgi.py +260 -0
- superqode/providers/local/tool_support.py +477 -0
- superqode/providers/local/vllm.py +258 -0
- superqode/providers/manager.py +1338 -0
- superqode/providers/models.py +1016 -0
- superqode/providers/models_dev.py +578 -0
- superqode/providers/openresponses/__init__.py +87 -0
- superqode/providers/openresponses/converters/__init__.py +17 -0
- superqode/providers/openresponses/converters/messages.py +343 -0
- superqode/providers/openresponses/converters/tools.py +268 -0
- superqode/providers/openresponses/schema/__init__.py +56 -0
- superqode/providers/openresponses/schema/models.py +585 -0
- superqode/providers/openresponses/streaming/__init__.py +5 -0
- superqode/providers/openresponses/streaming/parser.py +338 -0
- superqode/providers/openresponses/tools/__init__.py +21 -0
- superqode/providers/openresponses/tools/apply_patch.py +352 -0
- superqode/providers/openresponses/tools/code_interpreter.py +290 -0
- superqode/providers/openresponses/tools/file_search.py +333 -0
- superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
- superqode/providers/registry.py +716 -0
- superqode/providers/usage.py +332 -0
- superqode/pure_mode.py +384 -0
- superqode/qr/__init__.py +23 -0
- superqode/qr/dashboard.py +781 -0
- superqode/qr/generator.py +1018 -0
- superqode/qr/templates.py +135 -0
- superqode/safety/__init__.py +41 -0
- superqode/safety/sandbox.py +413 -0
- superqode/safety/warnings.py +256 -0
- superqode/server/__init__.py +33 -0
- superqode/server/lsp_server.py +775 -0
- superqode/server/web.py +250 -0
- superqode/session/__init__.py +25 -0
- superqode/session/persistence.py +580 -0
- superqode/session/sharing.py +477 -0
- superqode/session.py +475 -0
- superqode/sidebar.py +2991 -0
- superqode/stream_view.py +648 -0
- superqode/styles/__init__.py +3 -0
- superqode/superqe/__init__.py +184 -0
- superqode/superqe/acp_runner.py +1064 -0
- superqode/superqe/constitution/__init__.py +62 -0
- superqode/superqe/constitution/evaluator.py +308 -0
- superqode/superqe/constitution/loader.py +432 -0
- superqode/superqe/constitution/schema.py +250 -0
- superqode/superqe/events.py +591 -0
- superqode/superqe/frameworks/__init__.py +65 -0
- superqode/superqe/frameworks/base.py +234 -0
- superqode/superqe/frameworks/e2e.py +263 -0
- superqode/superqe/frameworks/executor.py +237 -0
- superqode/superqe/frameworks/javascript.py +409 -0
- superqode/superqe/frameworks/python.py +373 -0
- superqode/superqe/frameworks/registry.py +92 -0
- superqode/superqe/mcp_tools/__init__.py +47 -0
- superqode/superqe/mcp_tools/core_tools.py +418 -0
- superqode/superqe/mcp_tools/registry.py +230 -0
- superqode/superqe/mcp_tools/testing_tools.py +167 -0
- superqode/superqe/noise.py +89 -0
- superqode/superqe/orchestrator.py +778 -0
- superqode/superqe/roles.py +609 -0
- superqode/superqe/session.py +713 -0
- superqode/superqe/skills/__init__.py +57 -0
- superqode/superqe/skills/base.py +106 -0
- superqode/superqe/skills/core_skills.py +899 -0
- superqode/superqe/skills/registry.py +90 -0
- superqode/superqe/verifier.py +101 -0
- superqode/superqe_cli.py +76 -0
- superqode/tool_call.py +358 -0
- superqode/tools/__init__.py +93 -0
- superqode/tools/agent_tools.py +496 -0
- superqode/tools/base.py +324 -0
- superqode/tools/batch_tool.py +133 -0
- superqode/tools/diagnostics.py +311 -0
- superqode/tools/edit_tools.py +653 -0
- superqode/tools/enhanced_base.py +515 -0
- superqode/tools/file_tools.py +269 -0
- superqode/tools/file_tracking.py +45 -0
- superqode/tools/lsp_tools.py +610 -0
- superqode/tools/network_tools.py +350 -0
- superqode/tools/permissions.py +400 -0
- superqode/tools/question_tool.py +324 -0
- superqode/tools/search_tools.py +598 -0
- superqode/tools/shell_tools.py +259 -0
- superqode/tools/todo_tools.py +121 -0
- superqode/tools/validation.py +80 -0
- superqode/tools/web_tools.py +639 -0
- superqode/tui.py +1152 -0
- superqode/tui_integration.py +875 -0
- superqode/tui_widgets/__init__.py +27 -0
- superqode/tui_widgets/widgets/__init__.py +18 -0
- superqode/tui_widgets/widgets/progress.py +185 -0
- superqode/tui_widgets/widgets/tool_display.py +188 -0
- superqode/undo_manager.py +574 -0
- superqode/utils/__init__.py +5 -0
- superqode/utils/error_handling.py +323 -0
- superqode/utils/fuzzy.py +257 -0
- superqode/widgets/__init__.py +477 -0
- superqode/widgets/agent_collab.py +390 -0
- superqode/widgets/agent_store.py +936 -0
- superqode/widgets/agent_switcher.py +395 -0
- superqode/widgets/animation_manager.py +284 -0
- superqode/widgets/code_context.py +356 -0
- superqode/widgets/command_palette.py +412 -0
- superqode/widgets/connection_status.py +537 -0
- superqode/widgets/conversation_history.py +470 -0
- superqode/widgets/diff_indicator.py +155 -0
- superqode/widgets/enhanced_status_bar.py +385 -0
- superqode/widgets/enhanced_toast.py +476 -0
- superqode/widgets/file_browser.py +809 -0
- superqode/widgets/file_reference.py +585 -0
- superqode/widgets/issue_timeline.py +340 -0
- superqode/widgets/leader_key.py +264 -0
- superqode/widgets/mode_switcher.py +445 -0
- superqode/widgets/model_picker.py +234 -0
- superqode/widgets/permission_preview.py +1205 -0
- superqode/widgets/prompt.py +358 -0
- superqode/widgets/provider_connect.py +725 -0
- superqode/widgets/pty_shell.py +587 -0
- superqode/widgets/qe_dashboard.py +321 -0
- superqode/widgets/resizable_sidebar.py +377 -0
- superqode/widgets/response_changes.py +218 -0
- superqode/widgets/response_display.py +528 -0
- superqode/widgets/rich_tool_display.py +613 -0
- superqode/widgets/sidebar_panels.py +1180 -0
- superqode/widgets/slash_complete.py +356 -0
- superqode/widgets/split_view.py +612 -0
- superqode/widgets/status_bar.py +273 -0
- superqode/widgets/superqode_display.py +786 -0
- superqode/widgets/thinking_display.py +815 -0
- superqode/widgets/throbber.py +87 -0
- superqode/widgets/toast.py +206 -0
- superqode/widgets/unified_output.py +1073 -0
- superqode/workspace/__init__.py +75 -0
- superqode/workspace/artifacts.py +472 -0
- superqode/workspace/coordinator.py +353 -0
- superqode/workspace/diff_tracker.py +429 -0
- superqode/workspace/git_guard.py +373 -0
- superqode/workspace/git_snapshot.py +526 -0
- superqode/workspace/manager.py +750 -0
- superqode/workspace/snapshot.py +357 -0
- superqode/workspace/watcher.py +535 -0
- superqode/workspace/worktree.py +440 -0
- superqode-0.1.5.dist-info/METADATA +204 -0
- superqode-0.1.5.dist-info/RECORD +288 -0
- superqode-0.1.5.dist-info/WHEEL +5 -0
- superqode-0.1.5.dist-info/entry_points.txt +3 -0
- superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
- 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
|
+
]
|