kweaver-dolphin 0.1.0__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.
- DolphinLanguageSDK/__init__.py +58 -0
- dolphin/__init__.py +62 -0
- dolphin/cli/__init__.py +20 -0
- dolphin/cli/args/__init__.py +9 -0
- dolphin/cli/args/parser.py +567 -0
- dolphin/cli/builtin_agents/__init__.py +22 -0
- dolphin/cli/commands/__init__.py +4 -0
- dolphin/cli/interrupt/__init__.py +8 -0
- dolphin/cli/interrupt/handler.py +205 -0
- dolphin/cli/interrupt/keyboard.py +82 -0
- dolphin/cli/main.py +49 -0
- dolphin/cli/multimodal/__init__.py +34 -0
- dolphin/cli/multimodal/clipboard.py +327 -0
- dolphin/cli/multimodal/handler.py +249 -0
- dolphin/cli/multimodal/image_processor.py +214 -0
- dolphin/cli/multimodal/input_parser.py +149 -0
- dolphin/cli/runner/__init__.py +8 -0
- dolphin/cli/runner/runner.py +989 -0
- dolphin/cli/ui/__init__.py +10 -0
- dolphin/cli/ui/console.py +2795 -0
- dolphin/cli/ui/input.py +340 -0
- dolphin/cli/ui/layout.py +425 -0
- dolphin/cli/ui/stream_renderer.py +302 -0
- dolphin/cli/utils/__init__.py +8 -0
- dolphin/cli/utils/helpers.py +135 -0
- dolphin/cli/utils/version.py +49 -0
- dolphin/core/__init__.py +107 -0
- dolphin/core/agent/__init__.py +10 -0
- dolphin/core/agent/agent_state.py +69 -0
- dolphin/core/agent/base_agent.py +970 -0
- dolphin/core/code_block/__init__.py +0 -0
- dolphin/core/code_block/agent_init_block.py +0 -0
- dolphin/core/code_block/assign_block.py +98 -0
- dolphin/core/code_block/basic_code_block.py +1865 -0
- dolphin/core/code_block/explore_block.py +1327 -0
- dolphin/core/code_block/explore_block_v2.py +712 -0
- dolphin/core/code_block/explore_strategy.py +672 -0
- dolphin/core/code_block/judge_block.py +220 -0
- dolphin/core/code_block/prompt_block.py +32 -0
- dolphin/core/code_block/skill_call_deduplicator.py +291 -0
- dolphin/core/code_block/tool_block.py +129 -0
- dolphin/core/common/__init__.py +17 -0
- dolphin/core/common/constants.py +176 -0
- dolphin/core/common/enums.py +1173 -0
- dolphin/core/common/exceptions.py +133 -0
- dolphin/core/common/multimodal.py +539 -0
- dolphin/core/common/object_type.py +165 -0
- dolphin/core/common/output_format.py +432 -0
- dolphin/core/common/types.py +36 -0
- dolphin/core/config/__init__.py +16 -0
- dolphin/core/config/global_config.py +1289 -0
- dolphin/core/config/ontology_config.py +133 -0
- dolphin/core/context/__init__.py +12 -0
- dolphin/core/context/context.py +1580 -0
- dolphin/core/context/context_manager.py +161 -0
- dolphin/core/context/var_output.py +82 -0
- dolphin/core/context/variable_pool.py +356 -0
- dolphin/core/context_engineer/__init__.py +41 -0
- dolphin/core/context_engineer/config/__init__.py +5 -0
- dolphin/core/context_engineer/config/settings.py +402 -0
- dolphin/core/context_engineer/core/__init__.py +7 -0
- dolphin/core/context_engineer/core/budget_manager.py +327 -0
- dolphin/core/context_engineer/core/context_assembler.py +583 -0
- dolphin/core/context_engineer/core/context_manager.py +637 -0
- dolphin/core/context_engineer/core/tokenizer_service.py +260 -0
- dolphin/core/context_engineer/example/incremental_example.py +267 -0
- dolphin/core/context_engineer/example/traditional_example.py +334 -0
- dolphin/core/context_engineer/services/__init__.py +5 -0
- dolphin/core/context_engineer/services/compressor.py +399 -0
- dolphin/core/context_engineer/utils/__init__.py +6 -0
- dolphin/core/context_engineer/utils/context_utils.py +441 -0
- dolphin/core/context_engineer/utils/message_formatter.py +270 -0
- dolphin/core/context_engineer/utils/token_utils.py +139 -0
- dolphin/core/coroutine/__init__.py +15 -0
- dolphin/core/coroutine/context_snapshot.py +154 -0
- dolphin/core/coroutine/context_snapshot_profile.py +922 -0
- dolphin/core/coroutine/context_snapshot_store.py +268 -0
- dolphin/core/coroutine/execution_frame.py +145 -0
- dolphin/core/coroutine/execution_state_registry.py +161 -0
- dolphin/core/coroutine/resume_handle.py +101 -0
- dolphin/core/coroutine/step_result.py +101 -0
- dolphin/core/executor/__init__.py +18 -0
- dolphin/core/executor/debug_controller.py +630 -0
- dolphin/core/executor/dolphin_executor.py +1063 -0
- dolphin/core/executor/executor.py +624 -0
- dolphin/core/flags/__init__.py +27 -0
- dolphin/core/flags/definitions.py +49 -0
- dolphin/core/flags/manager.py +113 -0
- dolphin/core/hook/__init__.py +95 -0
- dolphin/core/hook/expression_evaluator.py +499 -0
- dolphin/core/hook/hook_dispatcher.py +380 -0
- dolphin/core/hook/hook_types.py +248 -0
- dolphin/core/hook/isolated_variable_pool.py +284 -0
- dolphin/core/interfaces.py +53 -0
- dolphin/core/llm/__init__.py +0 -0
- dolphin/core/llm/llm.py +495 -0
- dolphin/core/llm/llm_call.py +100 -0
- dolphin/core/llm/llm_client.py +1285 -0
- dolphin/core/llm/message_sanitizer.py +120 -0
- dolphin/core/logging/__init__.py +20 -0
- dolphin/core/logging/logger.py +526 -0
- dolphin/core/message/__init__.py +8 -0
- dolphin/core/message/compressor.py +749 -0
- dolphin/core/parser/__init__.py +8 -0
- dolphin/core/parser/parser.py +405 -0
- dolphin/core/runtime/__init__.py +10 -0
- dolphin/core/runtime/runtime_graph.py +926 -0
- dolphin/core/runtime/runtime_instance.py +446 -0
- dolphin/core/skill/__init__.py +14 -0
- dolphin/core/skill/context_retention.py +157 -0
- dolphin/core/skill/skill_function.py +686 -0
- dolphin/core/skill/skill_matcher.py +282 -0
- dolphin/core/skill/skillkit.py +700 -0
- dolphin/core/skill/skillset.py +72 -0
- dolphin/core/trajectory/__init__.py +10 -0
- dolphin/core/trajectory/recorder.py +189 -0
- dolphin/core/trajectory/trajectory.py +522 -0
- dolphin/core/utils/__init__.py +9 -0
- dolphin/core/utils/cache_kv.py +212 -0
- dolphin/core/utils/tools.py +340 -0
- dolphin/lib/__init__.py +93 -0
- dolphin/lib/debug/__init__.py +8 -0
- dolphin/lib/debug/visualizer.py +409 -0
- dolphin/lib/memory/__init__.py +28 -0
- dolphin/lib/memory/async_processor.py +220 -0
- dolphin/lib/memory/llm_calls.py +195 -0
- dolphin/lib/memory/manager.py +78 -0
- dolphin/lib/memory/sandbox.py +46 -0
- dolphin/lib/memory/storage.py +245 -0
- dolphin/lib/memory/utils.py +51 -0
- dolphin/lib/ontology/__init__.py +12 -0
- dolphin/lib/ontology/basic/__init__.py +0 -0
- dolphin/lib/ontology/basic/base.py +102 -0
- dolphin/lib/ontology/basic/concept.py +130 -0
- dolphin/lib/ontology/basic/object.py +11 -0
- dolphin/lib/ontology/basic/relation.py +63 -0
- dolphin/lib/ontology/datasource/__init__.py +27 -0
- dolphin/lib/ontology/datasource/datasource.py +66 -0
- dolphin/lib/ontology/datasource/oracle_datasource.py +338 -0
- dolphin/lib/ontology/datasource/sql.py +845 -0
- dolphin/lib/ontology/mapping.py +177 -0
- dolphin/lib/ontology/ontology.py +733 -0
- dolphin/lib/ontology/ontology_context.py +16 -0
- dolphin/lib/ontology/ontology_manager.py +107 -0
- dolphin/lib/skill_results/__init__.py +31 -0
- dolphin/lib/skill_results/cache_backend.py +559 -0
- dolphin/lib/skill_results/result_processor.py +181 -0
- dolphin/lib/skill_results/result_reference.py +179 -0
- dolphin/lib/skill_results/skillkit_hook.py +324 -0
- dolphin/lib/skill_results/strategies.py +328 -0
- dolphin/lib/skill_results/strategy_registry.py +150 -0
- dolphin/lib/skillkits/__init__.py +44 -0
- dolphin/lib/skillkits/agent_skillkit.py +155 -0
- dolphin/lib/skillkits/cognitive_skillkit.py +82 -0
- dolphin/lib/skillkits/env_skillkit.py +250 -0
- dolphin/lib/skillkits/mcp_adapter.py +616 -0
- dolphin/lib/skillkits/mcp_skillkit.py +771 -0
- dolphin/lib/skillkits/memory_skillkit.py +650 -0
- dolphin/lib/skillkits/noop_skillkit.py +31 -0
- dolphin/lib/skillkits/ontology_skillkit.py +89 -0
- dolphin/lib/skillkits/plan_act_skillkit.py +452 -0
- dolphin/lib/skillkits/resource/__init__.py +52 -0
- dolphin/lib/skillkits/resource/models/__init__.py +6 -0
- dolphin/lib/skillkits/resource/models/skill_config.py +109 -0
- dolphin/lib/skillkits/resource/models/skill_meta.py +127 -0
- dolphin/lib/skillkits/resource/resource_skillkit.py +393 -0
- dolphin/lib/skillkits/resource/skill_cache.py +215 -0
- dolphin/lib/skillkits/resource/skill_loader.py +395 -0
- dolphin/lib/skillkits/resource/skill_validator.py +406 -0
- dolphin/lib/skillkits/resource_skillkit.py +11 -0
- dolphin/lib/skillkits/search_skillkit.py +163 -0
- dolphin/lib/skillkits/sql_skillkit.py +274 -0
- dolphin/lib/skillkits/system_skillkit.py +509 -0
- dolphin/lib/skillkits/vm_skillkit.py +65 -0
- dolphin/lib/utils/__init__.py +9 -0
- dolphin/lib/utils/data_process.py +207 -0
- dolphin/lib/utils/handle_progress.py +178 -0
- dolphin/lib/utils/security.py +139 -0
- dolphin/lib/utils/text_retrieval.py +462 -0
- dolphin/lib/vm/__init__.py +11 -0
- dolphin/lib/vm/env_executor.py +895 -0
- dolphin/lib/vm/python_session_manager.py +453 -0
- dolphin/lib/vm/vm.py +610 -0
- dolphin/sdk/__init__.py +60 -0
- dolphin/sdk/agent/__init__.py +12 -0
- dolphin/sdk/agent/agent_factory.py +236 -0
- dolphin/sdk/agent/dolphin_agent.py +1106 -0
- dolphin/sdk/api/__init__.py +4 -0
- dolphin/sdk/runtime/__init__.py +8 -0
- dolphin/sdk/runtime/env.py +363 -0
- dolphin/sdk/skill/__init__.py +10 -0
- dolphin/sdk/skill/global_skills.py +706 -0
- dolphin/sdk/skill/traditional_toolkit.py +260 -0
- kweaver_dolphin-0.1.0.dist-info/METADATA +521 -0
- kweaver_dolphin-0.1.0.dist-info/RECORD +199 -0
- kweaver_dolphin-0.1.0.dist-info/WHEEL +5 -0
- kweaver_dolphin-0.1.0.dist-info/entry_points.txt +27 -0
- kweaver_dolphin-0.1.0.dist-info/licenses/LICENSE.txt +201 -0
- kweaver_dolphin-0.1.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,2795 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Console UI Module - Modern Terminal Display for Dolphin SDK
|
|
3
|
+
|
|
4
|
+
This module provides a modern, visually appealing terminal UI for displaying
|
|
5
|
+
tool calls, skill invocations, and agent interactions. Inspired by Codex CLI
|
|
6
|
+
and Claude Code's elegant terminal interfaces.
|
|
7
|
+
|
|
8
|
+
Features:
|
|
9
|
+
- Card-style bordered boxes with Unicode box-drawing characters
|
|
10
|
+
- Syntax highlighting for JSON parameters
|
|
11
|
+
- Status indicators (running/completed/error)
|
|
12
|
+
- Compact yet readable JSON formatting
|
|
13
|
+
- Spinner animations for long-running operations
|
|
14
|
+
- Harmonious color palette
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import ast
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
import unicodedata
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from enum import Enum
|
|
25
|
+
from typing import Any, Dict, Optional, List
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ─────────────────────────────────────────────────────────────
|
|
29
|
+
# Global stdout coordination lock
|
|
30
|
+
# Prevents race conditions between spinner threads and main output
|
|
31
|
+
# ─────────────────────────────────────────────────────────────
|
|
32
|
+
_stdout_lock = threading.RLock()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def safe_write(text: str, flush: bool = True) -> None:
|
|
36
|
+
"""Thread-safe stdout write.
|
|
37
|
+
|
|
38
|
+
Use this instead of sys.stdout.write() when outputting text
|
|
39
|
+
that might conflict with spinner animations.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
text: Text to write to stdout
|
|
43
|
+
flush: Whether to flush after writing
|
|
44
|
+
"""
|
|
45
|
+
with _stdout_lock:
|
|
46
|
+
sys.stdout.write(text)
|
|
47
|
+
if flush:
|
|
48
|
+
sys.stdout.flush()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def safe_print(*args, **kwargs) -> None:
|
|
52
|
+
"""Thread-safe print function.
|
|
53
|
+
|
|
54
|
+
Use this instead of print() when outputting text
|
|
55
|
+
that might conflict with spinner animations.
|
|
56
|
+
"""
|
|
57
|
+
with _stdout_lock:
|
|
58
|
+
print(*args, **kwargs)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Theme:
|
|
62
|
+
"""Modern color theme inspired by Codex and Claude Code"""
|
|
63
|
+
|
|
64
|
+
# ANSI escape codes
|
|
65
|
+
RESET = "\033[0m"
|
|
66
|
+
BOLD = "\033[1m"
|
|
67
|
+
DIM = "\033[2m"
|
|
68
|
+
ITALIC = "\033[3m"
|
|
69
|
+
UNDERLINE = "\033[4m"
|
|
70
|
+
|
|
71
|
+
# Primary colors (muted, modern palette)
|
|
72
|
+
PRIMARY = "\033[38;5;75m" # Soft blue
|
|
73
|
+
SECONDARY = "\033[38;5;183m" # Soft purple
|
|
74
|
+
ACCENT = "\033[38;5;216m" # Peach/coral
|
|
75
|
+
SUCCESS = "\033[38;5;114m" # Soft green
|
|
76
|
+
WARNING = "\033[38;5;221m" # Soft yellow
|
|
77
|
+
ERROR = "\033[38;5;210m" # Soft red
|
|
78
|
+
|
|
79
|
+
# Semantic colors
|
|
80
|
+
TOOL_NAME = "\033[38;5;75m" # Bright blue for tool names
|
|
81
|
+
PARAM_KEY = "\033[38;5;183m" # Purple for parameter keys
|
|
82
|
+
PARAM_VALUE = "\033[38;5;223m" # Warm white for values
|
|
83
|
+
STRING_VALUE = "\033[38;5;114m" # Green for strings
|
|
84
|
+
NUMBER_VALUE = "\033[38;5;216m" # Coral for numbers
|
|
85
|
+
BOOLEAN_VALUE = "\033[38;5;221m"# Yellow for booleans
|
|
86
|
+
NULL_VALUE = "\033[38;5;245m" # Gray for null
|
|
87
|
+
|
|
88
|
+
# UI elements
|
|
89
|
+
BORDER = "\033[38;5;240m" # Dark gray for borders
|
|
90
|
+
BORDER_ACCENT = "\033[38;5;75m" # Blue accent for active borders
|
|
91
|
+
LABEL = "\033[38;5;250m" # Light gray for labels
|
|
92
|
+
MUTED = "\033[38;5;245m" # Muted text
|
|
93
|
+
|
|
94
|
+
# Box drawing characters
|
|
95
|
+
BOX_TOP_LEFT = "╭"
|
|
96
|
+
BOX_TOP_RIGHT = "╮"
|
|
97
|
+
BOX_BOTTOM_LEFT = "╰"
|
|
98
|
+
BOX_BOTTOM_RIGHT = "╯"
|
|
99
|
+
BOX_HORIZONTAL = "─"
|
|
100
|
+
BOX_VERTICAL = "│"
|
|
101
|
+
BOX_ARROW_RIGHT = "▶"
|
|
102
|
+
BOX_ARROW_LEFT = "◀"
|
|
103
|
+
BOX_DOT = "●"
|
|
104
|
+
BOX_CIRCLE = "○"
|
|
105
|
+
BOX_CHECK = "✓"
|
|
106
|
+
BOX_CROSS = "✗"
|
|
107
|
+
BOX_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
108
|
+
|
|
109
|
+
# Heavy box drawing characters for banner border
|
|
110
|
+
HEAVY_TOP_LEFT = "┏"
|
|
111
|
+
HEAVY_TOP_RIGHT = "┓"
|
|
112
|
+
HEAVY_BOTTOM_LEFT = "┗"
|
|
113
|
+
HEAVY_BOTTOM_RIGHT = "┛"
|
|
114
|
+
HEAVY_HORIZONTAL = "━"
|
|
115
|
+
HEAVY_VERTICAL = "┃"
|
|
116
|
+
|
|
117
|
+
# ASCII Art Banner - Large pixel letters with shadow effect
|
|
118
|
+
# Double-layer hollow design: outer frame (█) + inner hollow (░)
|
|
119
|
+
# Each letter is 9 chars wide x 6 rows tall
|
|
120
|
+
# Modern minimalist style with single color
|
|
121
|
+
BANNER_LETTERS = {
|
|
122
|
+
'D': [
|
|
123
|
+
"████████ ",
|
|
124
|
+
"██░░░░██ ",
|
|
125
|
+
"██░░ ░█ ",
|
|
126
|
+
"██░░ ░█ ",
|
|
127
|
+
"██░░░░██ ",
|
|
128
|
+
"████████ ",
|
|
129
|
+
],
|
|
130
|
+
'O': [
|
|
131
|
+
" ██████ ",
|
|
132
|
+
"██░░░░██ ",
|
|
133
|
+
"█░░ ░░█ ",
|
|
134
|
+
"█░░ ░░█ ",
|
|
135
|
+
"██░░░░██ ",
|
|
136
|
+
" ██████ ",
|
|
137
|
+
],
|
|
138
|
+
'L': [
|
|
139
|
+
"██░░ ",
|
|
140
|
+
"██░░ ",
|
|
141
|
+
"██░░ ",
|
|
142
|
+
"██░░ ",
|
|
143
|
+
"██░░░░░░ ",
|
|
144
|
+
"████████ ",
|
|
145
|
+
],
|
|
146
|
+
'P': [
|
|
147
|
+
"████████ ",
|
|
148
|
+
"██░░░░██ ",
|
|
149
|
+
"██░░░░██ ",
|
|
150
|
+
"████████ ",
|
|
151
|
+
"██░░ ",
|
|
152
|
+
"██░░ ",
|
|
153
|
+
],
|
|
154
|
+
'H': [
|
|
155
|
+
"██░░ ░██ ",
|
|
156
|
+
"██░░ ░██ ",
|
|
157
|
+
"████████ ",
|
|
158
|
+
"██░░░░██ ",
|
|
159
|
+
"██░░ ░██ ",
|
|
160
|
+
"██░░ ░██ ",
|
|
161
|
+
],
|
|
162
|
+
'I': [
|
|
163
|
+
"████████ ",
|
|
164
|
+
" ░░██░░ ",
|
|
165
|
+
" ██ ",
|
|
166
|
+
" ██ ",
|
|
167
|
+
" ░░██░░ ",
|
|
168
|
+
"████████ ",
|
|
169
|
+
],
|
|
170
|
+
'N': [
|
|
171
|
+
"██░░ ░██ ",
|
|
172
|
+
"███░ ░██ ",
|
|
173
|
+
"██░█░░██ ",
|
|
174
|
+
"██░░█░██ ",
|
|
175
|
+
"██░ ░███ ",
|
|
176
|
+
"██░░ ░██ ",
|
|
177
|
+
],
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# Letter order - single color design (no gradient)
|
|
181
|
+
BANNER_WORD = "DOLPHIN"
|
|
182
|
+
# Unified color for hollow design (cyan/teal)
|
|
183
|
+
BANNER_COLOR = "\033[38;5;80m"
|
|
184
|
+
# Inner hollow color (darker, creates depth)
|
|
185
|
+
BANNER_HOLLOW_COLOR = "\033[38;5;238m"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class StatusType(Enum):
|
|
189
|
+
"""Status types for visual indicators"""
|
|
190
|
+
PENDING = "pending"
|
|
191
|
+
RUNNING = "running"
|
|
192
|
+
SUCCESS = "success"
|
|
193
|
+
ERROR = "error"
|
|
194
|
+
SKIPPED = "skipped"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass
|
|
198
|
+
class BoxStyle:
|
|
199
|
+
"""Box drawing style configuration"""
|
|
200
|
+
width: int = 80
|
|
201
|
+
padding: int = 1
|
|
202
|
+
show_border: bool = True
|
|
203
|
+
border_color: str = Theme.BORDER
|
|
204
|
+
accent_color: str = Theme.BORDER_ACCENT
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class Spinner:
|
|
208
|
+
"""Animated spinner for long-running operations.
|
|
209
|
+
|
|
210
|
+
Uses the global _stdout_lock to prevent output conflicts with
|
|
211
|
+
other threads writing to stdout.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
def __init__(self, message: str = "Processing", position_updates: Optional[List[Dict[str, int]]] = None):
|
|
215
|
+
"""
|
|
216
|
+
Args:
|
|
217
|
+
message: Text to display alongside the bottom spinner.
|
|
218
|
+
position_updates: Optional list of relative positions to update synchronously.
|
|
219
|
+
Each dict should have 'up' (lines up) and 'col' (column index).
|
|
220
|
+
Example: [{'up': 5, 'col': 4}] updates the character 5 lines up at col 4.
|
|
221
|
+
"""
|
|
222
|
+
self.message = message
|
|
223
|
+
self.running = False
|
|
224
|
+
self.thread: Optional[threading.Thread] = None
|
|
225
|
+
self.frame_index = 0
|
|
226
|
+
self.position_updates = position_updates or []
|
|
227
|
+
|
|
228
|
+
def _animate(self):
|
|
229
|
+
frames = Theme.BOX_SPINNER_FRAMES
|
|
230
|
+
while self.running:
|
|
231
|
+
frame = frames[self.frame_index % len(frames)]
|
|
232
|
+
|
|
233
|
+
# Use global lock to prevent conflicts with main thread output
|
|
234
|
+
with _stdout_lock:
|
|
235
|
+
# Build the entire output as a single string for atomic write
|
|
236
|
+
output_parts = []
|
|
237
|
+
|
|
238
|
+
# 1. Bottom line spinner
|
|
239
|
+
output_parts.append(f"\r{Theme.PRIMARY}{frame}{Theme.RESET} {Theme.LABEL}{self.message}{Theme.RESET}")
|
|
240
|
+
|
|
241
|
+
# 2. Update remote positions (e.g., Box Header) if any
|
|
242
|
+
if self.position_updates:
|
|
243
|
+
# Save cursor position (DEC sequence \0337 is widely supported)
|
|
244
|
+
output_parts.append("\0337")
|
|
245
|
+
|
|
246
|
+
for pos in self.position_updates:
|
|
247
|
+
lines_up = pos.get('up', 0)
|
|
248
|
+
column = pos.get('col', 0)
|
|
249
|
+
if lines_up > 0:
|
|
250
|
+
# Move up N lines, then move to specific column
|
|
251
|
+
# \033[NA (Up), \033[MG (Column M)
|
|
252
|
+
output_parts.append(f"\033[{lines_up}A\033[{column}G")
|
|
253
|
+
output_parts.append(f"{Theme.PRIMARY}{frame}{Theme.RESET}")
|
|
254
|
+
|
|
255
|
+
# Restore cursor position (DEC sequence \0338)
|
|
256
|
+
output_parts.append("\0338")
|
|
257
|
+
|
|
258
|
+
# Single atomic write
|
|
259
|
+
sys.stdout.write("".join(output_parts))
|
|
260
|
+
sys.stdout.flush()
|
|
261
|
+
|
|
262
|
+
self.frame_index += 1
|
|
263
|
+
time.sleep(0.08)
|
|
264
|
+
|
|
265
|
+
# Clear the bottom line when done (also protected by lock)
|
|
266
|
+
with _stdout_lock:
|
|
267
|
+
sys.stdout.write("\r" + " " * (len(self.message) + 10) + "\r")
|
|
268
|
+
sys.stdout.flush()
|
|
269
|
+
|
|
270
|
+
def start(self):
|
|
271
|
+
self.running = True
|
|
272
|
+
self.thread = threading.Thread(target=self._animate, daemon=True)
|
|
273
|
+
self.thread.start()
|
|
274
|
+
|
|
275
|
+
def stop(self, success: bool = True):
|
|
276
|
+
"""Stop the spinner and optionally update remote positions with a completion icon."""
|
|
277
|
+
self.running = False
|
|
278
|
+
if self.thread:
|
|
279
|
+
self.thread.join(timeout=0.5)
|
|
280
|
+
|
|
281
|
+
# Update remote positions with a static completion icon
|
|
282
|
+
if self.position_updates:
|
|
283
|
+
# Choose icon based on success status
|
|
284
|
+
completion_icon = "●" if success else "✗"
|
|
285
|
+
completion_color = Theme.SUCCESS if success else Theme.ERROR
|
|
286
|
+
|
|
287
|
+
sys.stdout.write("\0337") # Save cursor
|
|
288
|
+
for pos in self.position_updates:
|
|
289
|
+
lines_up = pos.get('up', 0)
|
|
290
|
+
column = pos.get('col', 0)
|
|
291
|
+
if lines_up > 0:
|
|
292
|
+
sys.stdout.write(f"\033[{lines_up}A\033[{column}G")
|
|
293
|
+
sys.stdout.write(f"{completion_color}{completion_icon}{Theme.RESET}")
|
|
294
|
+
sys.stdout.write("\0338") # Restore cursor
|
|
295
|
+
sys.stdout.flush()
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ─────────────────────────────────────────────────────────────
|
|
299
|
+
# Global StatusBar Coordination
|
|
300
|
+
# Prevents concurrent animations from conflicting with each other
|
|
301
|
+
# ─────────────────────────────────────────────────────────────
|
|
302
|
+
_active_status_bar: Optional['StatusBar'] = None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _pause_active_status_bar() -> Optional['StatusBar']:
|
|
306
|
+
"""Pause the active status bar if one exists.
|
|
307
|
+
|
|
308
|
+
Uses pause() instead of stop() so the timer continues running.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
The paused StatusBar instance (to resume later), or None
|
|
312
|
+
"""
|
|
313
|
+
global _active_status_bar
|
|
314
|
+
StatusBar._debug_log(f"_pause_active_status_bar: called, _active_status_bar={_active_status_bar is not None}, running={_active_status_bar.running if _active_status_bar else 'N/A'}")
|
|
315
|
+
if _active_status_bar and _active_status_bar.running:
|
|
316
|
+
_active_status_bar.pause()
|
|
317
|
+
StatusBar._debug_log(f"_pause_active_status_bar: paused StatusBar")
|
|
318
|
+
return _active_status_bar
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _resume_status_bar(status_bar: Optional['StatusBar']) -> None:
|
|
323
|
+
"""Resume a previously paused status bar.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
status_bar: The StatusBar instance to resume
|
|
327
|
+
"""
|
|
328
|
+
StatusBar._debug_log(f"_resume_status_bar: called, status_bar={status_bar is not None}, running={status_bar.running if status_bar else 'N/A'}")
|
|
329
|
+
if status_bar and status_bar.running:
|
|
330
|
+
status_bar.resume()
|
|
331
|
+
StatusBar._debug_log(f"_resume_status_bar: resumed StatusBar")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def set_active_status_bar(status_bar: Optional['StatusBar']) -> None:
|
|
335
|
+
"""Register the active status bar for coordination.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
status_bar: The StatusBar instance to track
|
|
339
|
+
"""
|
|
340
|
+
global _active_status_bar
|
|
341
|
+
_active_status_bar = status_bar
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
from contextlib import contextmanager
|
|
345
|
+
|
|
346
|
+
@contextmanager
|
|
347
|
+
def pause_status_bar_context():
|
|
348
|
+
"""Context manager to pause StatusBar during content output.
|
|
349
|
+
|
|
350
|
+
Use this to wrap any code that outputs content to the terminal
|
|
351
|
+
to prevent StatusBar animation from conflicting with the output.
|
|
352
|
+
|
|
353
|
+
Example:
|
|
354
|
+
with pause_status_bar_context():
|
|
355
|
+
print("Some output...")
|
|
356
|
+
"""
|
|
357
|
+
paused = _pause_active_status_bar()
|
|
358
|
+
try:
|
|
359
|
+
yield
|
|
360
|
+
finally:
|
|
361
|
+
_resume_status_bar(paused)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class LivePlanCard:
|
|
365
|
+
"""
|
|
366
|
+
Live-updating Plan Card with animated spinner.
|
|
367
|
+
|
|
368
|
+
Uses ANSI cursor control to refresh the entire card area
|
|
369
|
+
while maintaining the spinner animation.
|
|
370
|
+
|
|
371
|
+
Note: This class coordinates with StatusBar to prevent concurrent
|
|
372
|
+
animation conflicts. When LivePlanCard starts, it pauses any active
|
|
373
|
+
StatusBar and resumes it when stopped.
|
|
374
|
+
"""
|
|
375
|
+
|
|
376
|
+
# Color theme (Teal/Cyan)
|
|
377
|
+
PLAN_PRIMARY = "\033[38;5;44m"
|
|
378
|
+
PLAN_ACCENT = "\033[38;5;80m"
|
|
379
|
+
PLAN_MUTED = "\033[38;5;242m"
|
|
380
|
+
|
|
381
|
+
SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
382
|
+
|
|
383
|
+
def __init__(self):
|
|
384
|
+
self.running = False
|
|
385
|
+
self.thread: Optional[threading.Thread] = None
|
|
386
|
+
self.frame_index = 0
|
|
387
|
+
self.tasks: List[Dict[str, Any]] = []
|
|
388
|
+
self.current_task_id: Optional[int] = None
|
|
389
|
+
self.current_action: Optional[str] = None
|
|
390
|
+
self.current_task_content: Optional[str] = None
|
|
391
|
+
self.start_time: float = 0
|
|
392
|
+
self._lines_printed = 0
|
|
393
|
+
self._lock = threading.Lock()
|
|
394
|
+
self._paused_status_bar: Optional['StatusBar'] = None # Track paused StatusBar
|
|
395
|
+
|
|
396
|
+
def _get_visual_width(self, text: str) -> int:
|
|
397
|
+
"""Calculate visual width handling CJK and ANSI codes."""
|
|
398
|
+
clean_text = ""
|
|
399
|
+
skip = False
|
|
400
|
+
for char in text:
|
|
401
|
+
if char == "\033":
|
|
402
|
+
skip = True
|
|
403
|
+
if not skip:
|
|
404
|
+
clean_text += char
|
|
405
|
+
if skip and char == "m":
|
|
406
|
+
skip = False
|
|
407
|
+
|
|
408
|
+
width = 0
|
|
409
|
+
for char in clean_text:
|
|
410
|
+
if unicodedata.east_asian_width(char) in ("W", "F", "A"):
|
|
411
|
+
width += 2
|
|
412
|
+
else:
|
|
413
|
+
width += 1
|
|
414
|
+
return width
|
|
415
|
+
|
|
416
|
+
def _get_terminal_width(self) -> int:
|
|
417
|
+
try:
|
|
418
|
+
import shutil
|
|
419
|
+
return shutil.get_terminal_size().columns
|
|
420
|
+
except Exception:
|
|
421
|
+
return 80
|
|
422
|
+
|
|
423
|
+
def _build_card_lines(self) -> List[str]:
|
|
424
|
+
"""Build all lines of the plan card for rendering."""
|
|
425
|
+
lines = []
|
|
426
|
+
|
|
427
|
+
width = min(80, self._get_terminal_width() - 8)
|
|
428
|
+
if width < 40:
|
|
429
|
+
width = 40
|
|
430
|
+
|
|
431
|
+
total = len(self.tasks)
|
|
432
|
+
completed = sum(1 for t in self.tasks if t.get("status") in ("completed", "done", "success"))
|
|
433
|
+
|
|
434
|
+
# Header
|
|
435
|
+
title = "Plan Update"
|
|
436
|
+
progress = f"{completed}/{total}"
|
|
437
|
+
header_text = f" 📋 {title}"
|
|
438
|
+
v_header_w = self._get_visual_width(header_text)
|
|
439
|
+
v_progress_w = self._get_visual_width(progress)
|
|
440
|
+
header_padding = width - v_header_w - v_progress_w - 4
|
|
441
|
+
|
|
442
|
+
lines.append(f"{self.PLAN_PRIMARY}{Theme.BOX_TOP_LEFT}{Theme.BOX_HORIZONTAL * width}{Theme.BOX_TOP_RIGHT}{Theme.RESET}")
|
|
443
|
+
lines.append(f"{self.PLAN_PRIMARY}{Theme.BOX_VERTICAL}{Theme.RESET}{Theme.BOLD}{header_text}{Theme.RESET}{' ' * max(0, header_padding)}{self.PLAN_ACCENT}{progress}{Theme.RESET} {self.PLAN_PRIMARY}{Theme.BOX_VERTICAL}{Theme.RESET}")
|
|
444
|
+
lines.append(f"{self.PLAN_PRIMARY}{Theme.BOX_VERTICAL}{Theme.BOX_HORIZONTAL * width}{Theme.BOX_VERTICAL}{Theme.RESET}")
|
|
445
|
+
|
|
446
|
+
# Task list with animated spinner
|
|
447
|
+
current_frame = self.SPINNER_FRAMES[self.frame_index % len(self.SPINNER_FRAMES)]
|
|
448
|
+
|
|
449
|
+
for i, task in enumerate(self.tasks):
|
|
450
|
+
content = task.get("content", task.get("name", f"Task {i+1}"))
|
|
451
|
+
status = task.get("status", "pending").lower()
|
|
452
|
+
|
|
453
|
+
# Truncate content
|
|
454
|
+
max_v_content = width - 10
|
|
455
|
+
v_content = self._get_visual_width(content)
|
|
456
|
+
if v_content > max_v_content:
|
|
457
|
+
content = content[:int(max_v_content / 1.5)] + "..."
|
|
458
|
+
|
|
459
|
+
# Determine icon and color
|
|
460
|
+
is_current = (self.current_task_id is not None and i + 1 == self.current_task_id)
|
|
461
|
+
|
|
462
|
+
if is_current and status in ("pending", "running", "in_progress"):
|
|
463
|
+
icon, color = current_frame, self.PLAN_PRIMARY
|
|
464
|
+
elif status in ("completed", "done", "success"):
|
|
465
|
+
icon, color = "●", Theme.SUCCESS
|
|
466
|
+
elif status in ("running", "in_progress"):
|
|
467
|
+
icon, color = current_frame, self.PLAN_PRIMARY
|
|
468
|
+
else:
|
|
469
|
+
icon, color = "○", Theme.MUTED
|
|
470
|
+
|
|
471
|
+
if is_current:
|
|
472
|
+
task_line = f" {color}{icon}{Theme.RESET} {Theme.BOLD}{content}{Theme.RESET}"
|
|
473
|
+
indicator = f" {self.PLAN_PRIMARY}←{Theme.RESET}"
|
|
474
|
+
else:
|
|
475
|
+
task_line = f" {color}{icon}{Theme.RESET} {content}"
|
|
476
|
+
indicator = ""
|
|
477
|
+
|
|
478
|
+
v_line_w = self._get_visual_width(task_line)
|
|
479
|
+
v_indicator_w = self._get_visual_width(indicator)
|
|
480
|
+
padding = width - v_line_w - v_indicator_w - 1
|
|
481
|
+
|
|
482
|
+
lines.append(f"{self.PLAN_PRIMARY}{Theme.BOX_VERTICAL}{Theme.RESET}{task_line}{indicator}{' ' * max(0, padding)}{self.PLAN_PRIMARY}{Theme.BOX_VERTICAL}{Theme.RESET}")
|
|
483
|
+
|
|
484
|
+
# Footer with action and elapsed time
|
|
485
|
+
if self.current_action and self.current_task_id:
|
|
486
|
+
action_icons = {"create": "📝", "start": "▶", "done": "✓", "pause": "⏸", "skip": "⏭"}
|
|
487
|
+
action_icon = action_icons.get(self.current_action, "•")
|
|
488
|
+
|
|
489
|
+
lines.append(f"{self.PLAN_PRIMARY}{Theme.BOX_VERTICAL}{Theme.BOX_HORIZONTAL * width}{Theme.BOX_VERTICAL}{Theme.RESET}")
|
|
490
|
+
|
|
491
|
+
if self.current_task_content:
|
|
492
|
+
action_text = f" {action_icon} Task {self.current_task_id}: {self.current_task_content}"
|
|
493
|
+
else:
|
|
494
|
+
action_text = f" {action_icon} Task {self.current_task_id}"
|
|
495
|
+
|
|
496
|
+
v_action_w = self._get_visual_width(action_text)
|
|
497
|
+
if v_action_w > width - 4:
|
|
498
|
+
action_text = action_text[:int((width - 6) / 1.5)] + "..."
|
|
499
|
+
v_action_w = self._get_visual_width(action_text)
|
|
500
|
+
|
|
501
|
+
padding = width - v_action_w - 1
|
|
502
|
+
lines.append(f"{self.PLAN_PRIMARY}{Theme.BOX_VERTICAL}{Theme.RESET}{self.PLAN_ACCENT}{action_text}{Theme.RESET}{' ' * max(0, padding)}{self.PLAN_PRIMARY}{Theme.BOX_VERTICAL}{Theme.RESET}")
|
|
503
|
+
|
|
504
|
+
# Bottom border with timer
|
|
505
|
+
elapsed = int(time.time() - self.start_time)
|
|
506
|
+
timer_text = f" {elapsed}s • running "
|
|
507
|
+
v_timer_w = self._get_visual_width(timer_text)
|
|
508
|
+
left_len = (width - v_timer_w) // 2
|
|
509
|
+
right_len = width - v_timer_w - left_len
|
|
510
|
+
lines.append(f"{self.PLAN_PRIMARY}{Theme.BOX_BOTTOM_LEFT}{Theme.BOX_HORIZONTAL * left_len}{Theme.RESET}{Theme.MUTED}{timer_text}{Theme.RESET}{self.PLAN_PRIMARY}{Theme.BOX_HORIZONTAL * right_len}{Theme.BOX_BOTTOM_RIGHT}{Theme.RESET}")
|
|
511
|
+
|
|
512
|
+
return lines
|
|
513
|
+
|
|
514
|
+
def _animate(self):
|
|
515
|
+
"""Background thread animation loop.
|
|
516
|
+
|
|
517
|
+
Uses global _stdout_lock to coordinate with other output threads.
|
|
518
|
+
"""
|
|
519
|
+
while self.running:
|
|
520
|
+
with self._lock:
|
|
521
|
+
lines = self._build_card_lines()
|
|
522
|
+
|
|
523
|
+
# Use global stdout lock for atomic terminal output
|
|
524
|
+
with _stdout_lock:
|
|
525
|
+
# Move cursor up to overwrite previous card
|
|
526
|
+
if self._lines_printed > 0:
|
|
527
|
+
sys.stdout.write(f"\033[{self._lines_printed}A")
|
|
528
|
+
|
|
529
|
+
# Print new card
|
|
530
|
+
for line in lines:
|
|
531
|
+
sys.stdout.write(f"\033[K{line}\n")
|
|
532
|
+
sys.stdout.flush()
|
|
533
|
+
|
|
534
|
+
self._lines_printed = len(lines)
|
|
535
|
+
|
|
536
|
+
self.frame_index += 1
|
|
537
|
+
time.sleep(0.1)
|
|
538
|
+
|
|
539
|
+
def start(
|
|
540
|
+
self,
|
|
541
|
+
tasks: List[Dict[str, Any]],
|
|
542
|
+
current_task_id: Optional[int] = None,
|
|
543
|
+
current_action: Optional[str] = None,
|
|
544
|
+
current_task_content: Optional[str] = None
|
|
545
|
+
):
|
|
546
|
+
"""Start the live card animation.
|
|
547
|
+
|
|
548
|
+
Note: Fixed-position StatusBar uses cursor save/restore, so no pausing needed.
|
|
549
|
+
"""
|
|
550
|
+
self.tasks = tasks
|
|
551
|
+
self.current_task_id = current_task_id
|
|
552
|
+
self.current_action = current_action
|
|
553
|
+
self.current_task_content = current_task_content
|
|
554
|
+
self.start_time = time.time()
|
|
555
|
+
self.frame_index = 0
|
|
556
|
+
self._lines_printed = 0
|
|
557
|
+
|
|
558
|
+
# Print initial card
|
|
559
|
+
print()
|
|
560
|
+
lines = self._build_card_lines()
|
|
561
|
+
for line in lines:
|
|
562
|
+
print(line)
|
|
563
|
+
self._lines_printed = len(lines)
|
|
564
|
+
|
|
565
|
+
# Start animation thread
|
|
566
|
+
self.running = True
|
|
567
|
+
self.thread = threading.Thread(target=self._animate, daemon=True)
|
|
568
|
+
self.thread.start()
|
|
569
|
+
|
|
570
|
+
def update(
|
|
571
|
+
self,
|
|
572
|
+
tasks: Optional[List[Dict[str, Any]]] = None,
|
|
573
|
+
current_task_id: Optional[int] = None,
|
|
574
|
+
current_action: Optional[str] = None,
|
|
575
|
+
current_task_content: Optional[str] = None
|
|
576
|
+
):
|
|
577
|
+
"""Update the card data (thread-safe)."""
|
|
578
|
+
with self._lock:
|
|
579
|
+
if tasks is not None:
|
|
580
|
+
self.tasks = tasks
|
|
581
|
+
if current_task_id is not None:
|
|
582
|
+
self.current_task_id = current_task_id
|
|
583
|
+
if current_action is not None:
|
|
584
|
+
self.current_action = current_action
|
|
585
|
+
if current_task_content is not None:
|
|
586
|
+
self.current_task_content = current_task_content
|
|
587
|
+
|
|
588
|
+
def stop(self):
|
|
589
|
+
"""Stop the animation and show final state."""
|
|
590
|
+
self.running = False
|
|
591
|
+
if self.thread:
|
|
592
|
+
self.thread.join(timeout=0.5)
|
|
593
|
+
|
|
594
|
+
# Clear animation and ensure clean state
|
|
595
|
+
with self._lock:
|
|
596
|
+
if self._lines_printed > 0:
|
|
597
|
+
sys.stdout.write(f"\033[{self._lines_printed}A")
|
|
598
|
+
for _ in range(self._lines_printed):
|
|
599
|
+
sys.stdout.write("\033[K\n")
|
|
600
|
+
sys.stdout.write(f"\033[{self._lines_printed}A")
|
|
601
|
+
self._lines_printed = 0
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
class StatusBar:
|
|
605
|
+
"""
|
|
606
|
+
Animated status bar with spinner, message, and elapsed time.
|
|
607
|
+
|
|
608
|
+
Displays a line like:
|
|
609
|
+
⠋ I'm Feeling Lucky (esc to cancel, 3m 55s)
|
|
610
|
+
|
|
611
|
+
Features:
|
|
612
|
+
- Left: animated spinner
|
|
613
|
+
- Center: status message
|
|
614
|
+
- Right: elapsed time counter
|
|
615
|
+
"""
|
|
616
|
+
|
|
617
|
+
# Spinner frames
|
|
618
|
+
SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
619
|
+
|
|
620
|
+
# Status bar colors
|
|
621
|
+
STATUS_COLOR = "\033[38;5;214m" # Orange/gold
|
|
622
|
+
TIME_COLOR = "\033[38;5;245m" # Gray
|
|
623
|
+
HINT_COLOR = "\033[38;5;242m" # Dark gray
|
|
624
|
+
|
|
625
|
+
# Debug logging
|
|
626
|
+
_DEBUG_LOG_FILE = "/tmp/statusbar_debug.log"
|
|
627
|
+
_DEBUG_ENABLED = True
|
|
628
|
+
|
|
629
|
+
@classmethod
|
|
630
|
+
def _debug_log(cls, msg: str) -> None:
|
|
631
|
+
"""Write debug message to log file."""
|
|
632
|
+
if cls._DEBUG_ENABLED:
|
|
633
|
+
try:
|
|
634
|
+
with open(cls._DEBUG_LOG_FILE, "a") as f:
|
|
635
|
+
import datetime
|
|
636
|
+
ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
|
637
|
+
f.write(f"[{ts}] {msg}\n")
|
|
638
|
+
except:
|
|
639
|
+
pass
|
|
640
|
+
|
|
641
|
+
def __init__(self, message: str = "Processing", hint: str = "esc to cancel", fixed_row: Optional[int] = None):
|
|
642
|
+
"""Initialize the status bar.
|
|
643
|
+
|
|
644
|
+
Args:
|
|
645
|
+
message: Status message to display
|
|
646
|
+
hint: Hint text (shown in parentheses)
|
|
647
|
+
fixed_row: If set, render at this fixed row (1-indexed from top).
|
|
648
|
+
If None, render at current cursor position.
|
|
649
|
+
"""
|
|
650
|
+
self.message = message
|
|
651
|
+
self.hint = hint
|
|
652
|
+
self.fixed_row = fixed_row # Fixed screen row (1-indexed), or None for inline
|
|
653
|
+
self.running = False
|
|
654
|
+
self.paused = False # When True, animation continues but no output
|
|
655
|
+
self.thread: Optional[threading.Thread] = None
|
|
656
|
+
self.frame_index = 0
|
|
657
|
+
self.start_time = 0.0
|
|
658
|
+
self._lock = threading.Lock()
|
|
659
|
+
|
|
660
|
+
# Log initialization
|
|
661
|
+
self._debug_log(f"StatusBar.__init__: message={message!r}, hint={hint!r}, fixed_row={fixed_row}")
|
|
662
|
+
|
|
663
|
+
def _format_elapsed(self, seconds: int) -> str:
|
|
664
|
+
"""Format seconds as Xm Ys or Xs."""
|
|
665
|
+
if seconds >= 60:
|
|
666
|
+
mins = seconds // 60
|
|
667
|
+
secs = seconds % 60
|
|
668
|
+
return f"{mins}m {secs}s"
|
|
669
|
+
return f"{seconds}s"
|
|
670
|
+
|
|
671
|
+
def _build_line(self) -> str:
|
|
672
|
+
"""Build the status bar line."""
|
|
673
|
+
frame = self.SPINNER_FRAMES[self.frame_index % len(self.SPINNER_FRAMES)]
|
|
674
|
+
elapsed = int(time.time() - self.start_time)
|
|
675
|
+
elapsed_str = self._format_elapsed(elapsed)
|
|
676
|
+
|
|
677
|
+
# Format: ⠋ Message (hint, time)
|
|
678
|
+
line = (
|
|
679
|
+
f"{self.STATUS_COLOR}{frame}{Theme.RESET} "
|
|
680
|
+
f"{self.STATUS_COLOR}{self.message}{Theme.RESET} "
|
|
681
|
+
f"{self.HINT_COLOR}({self.hint}, {self.TIME_COLOR}{elapsed_str}{self.HINT_COLOR}){Theme.RESET}"
|
|
682
|
+
)
|
|
683
|
+
return line
|
|
684
|
+
|
|
685
|
+
def _animate(self):
|
|
686
|
+
"""Background thread animation loop.
|
|
687
|
+
|
|
688
|
+
Uses both the instance lock (self._lock) and global stdout lock (_stdout_lock)
|
|
689
|
+
to coordinate with other threads.
|
|
690
|
+
"""
|
|
691
|
+
self._debug_log(f"_animate: THREAD STARTED, fixed_row={self.fixed_row}")
|
|
692
|
+
loop_count = 0
|
|
693
|
+
while self.running:
|
|
694
|
+
loop_count += 1
|
|
695
|
+
with self._lock:
|
|
696
|
+
# Only write output if not paused
|
|
697
|
+
if not self.paused:
|
|
698
|
+
line = self._build_line()
|
|
699
|
+
|
|
700
|
+
# Ensure line doesn't exceed terminal width to prevent wrapping
|
|
701
|
+
try:
|
|
702
|
+
import shutil
|
|
703
|
+
width = shutil.get_terminal_size().columns
|
|
704
|
+
# Simplified truncation for extremely long strings
|
|
705
|
+
if len(line) > width * 3:
|
|
706
|
+
pass
|
|
707
|
+
except:
|
|
708
|
+
pass
|
|
709
|
+
|
|
710
|
+
# Use global stdout lock to prevent conflicts with other output
|
|
711
|
+
with _stdout_lock:
|
|
712
|
+
if self.fixed_row is not None:
|
|
713
|
+
# Fixed position mode: save, move, clear+draw, restore
|
|
714
|
+
# Combine into SINGLE write to minimize threading conflict
|
|
715
|
+
output = (
|
|
716
|
+
f"\0337" # Save cursor
|
|
717
|
+
f"\033[{self.fixed_row};1H" # Move to fixed row
|
|
718
|
+
f"\033[K" # Clear line
|
|
719
|
+
f"{line}" # Draw content
|
|
720
|
+
f"\0338" # Restore cursor
|
|
721
|
+
)
|
|
722
|
+
sys.stdout.write(output)
|
|
723
|
+
if loop_count <= 3: # Log first few loops
|
|
724
|
+
self._debug_log(f"_animate: loop={loop_count}, mode=FIXED, row={self.fixed_row}, output_repr={output!r}")
|
|
725
|
+
else:
|
|
726
|
+
# Inline mode
|
|
727
|
+
sys.stdout.write(f"\r\033[K{line}")
|
|
728
|
+
if loop_count <= 3: # Log first few loops
|
|
729
|
+
self._debug_log(f"_animate: loop={loop_count}, mode=INLINE, line_len={len(line)}")
|
|
730
|
+
|
|
731
|
+
sys.stdout.flush()
|
|
732
|
+
|
|
733
|
+
self.frame_index += 1
|
|
734
|
+
time.sleep(0.1)
|
|
735
|
+
|
|
736
|
+
self._debug_log(f"_animate: THREAD STOPPED after {loop_count} loops")
|
|
737
|
+
|
|
738
|
+
def start(self):
|
|
739
|
+
"""Start the status bar animation."""
|
|
740
|
+
self._debug_log(f"start: called, fixed_row={self.fixed_row}")
|
|
741
|
+
self.start_time = time.time()
|
|
742
|
+
self.frame_index = 0
|
|
743
|
+
self.running = True
|
|
744
|
+
self.paused = False
|
|
745
|
+
self.thread = threading.Thread(target=self._animate, daemon=True)
|
|
746
|
+
self.thread.start()
|
|
747
|
+
self._debug_log(f"start: thread started")
|
|
748
|
+
|
|
749
|
+
def pause(self):
|
|
750
|
+
"""Pause the status bar output (thread-safe).
|
|
751
|
+
|
|
752
|
+
The animation thread continues running but doesn't write to stdout.
|
|
753
|
+
Call resume() to continue output.
|
|
754
|
+
"""
|
|
755
|
+
with self._lock:
|
|
756
|
+
if not self.paused:
|
|
757
|
+
self.paused = True
|
|
758
|
+
# Clear the status bar line (use global lock for stdout)
|
|
759
|
+
with _stdout_lock:
|
|
760
|
+
if self.fixed_row is not None:
|
|
761
|
+
# Fixed position mode: clear the fixed row
|
|
762
|
+
output = (
|
|
763
|
+
f"\0337" # Save cursor
|
|
764
|
+
f"\033[{self.fixed_row};1H" # Move to fixed row
|
|
765
|
+
f"\033[K" # Clear line
|
|
766
|
+
f"\0338" # Restore cursor
|
|
767
|
+
)
|
|
768
|
+
sys.stdout.write(output)
|
|
769
|
+
else:
|
|
770
|
+
# Inline mode: clear current line and move to next line
|
|
771
|
+
# This ensures subsequent output doesn't mix with status bar
|
|
772
|
+
sys.stdout.write("\r\033[K\n")
|
|
773
|
+
sys.stdout.flush()
|
|
774
|
+
|
|
775
|
+
def resume(self):
|
|
776
|
+
"""Resume the status bar output (thread-safe).
|
|
777
|
+
|
|
778
|
+
Immediately redraws the status bar to ensure it's visible right away.
|
|
779
|
+
"""
|
|
780
|
+
with self._lock:
|
|
781
|
+
self.paused = False
|
|
782
|
+
# Immediately redraw to ensure visibility (use global lock for stdout)
|
|
783
|
+
line = self._build_line()
|
|
784
|
+
with _stdout_lock:
|
|
785
|
+
if self.fixed_row is not None:
|
|
786
|
+
output = (
|
|
787
|
+
f"\0337" # Save cursor
|
|
788
|
+
f"\033[{self.fixed_row};1H" # Move to fixed row
|
|
789
|
+
f"\033[K" # Clear line
|
|
790
|
+
f"{line}" # Draw content
|
|
791
|
+
f"\0338" # Restore cursor
|
|
792
|
+
)
|
|
793
|
+
sys.stdout.write(output)
|
|
794
|
+
else:
|
|
795
|
+
# Inline mode: redraw on current line
|
|
796
|
+
sys.stdout.write(f"\r\033[K{line}")
|
|
797
|
+
sys.stdout.flush()
|
|
798
|
+
|
|
799
|
+
def update_message(self, message: str):
|
|
800
|
+
"""Update the status message (thread-safe)."""
|
|
801
|
+
with self._lock:
|
|
802
|
+
self.message = message
|
|
803
|
+
|
|
804
|
+
def stop(self, clear: bool = True):
|
|
805
|
+
"""Stop the status bar animation."""
|
|
806
|
+
self.running = False
|
|
807
|
+
if self.thread:
|
|
808
|
+
self.thread.join(timeout=0.5)
|
|
809
|
+
|
|
810
|
+
if clear:
|
|
811
|
+
# Clear the status bar line (use global lock for stdout)
|
|
812
|
+
with _stdout_lock:
|
|
813
|
+
sys.stdout.write("\r\033[K")
|
|
814
|
+
sys.stdout.flush()
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
class FixedInputLayout:
|
|
818
|
+
"""
|
|
819
|
+
Terminal layout with fixed bottom input area and scrollable top content.
|
|
820
|
+
|
|
821
|
+
Uses ANSI scroll regions to create:
|
|
822
|
+
- Top area: scrollable content output
|
|
823
|
+
- Bottom area: fixed status bar + input prompt
|
|
824
|
+
|
|
825
|
+
Usage:
|
|
826
|
+
layout = FixedInputLayout(status_message="Ready")
|
|
827
|
+
layout.start()
|
|
828
|
+
|
|
829
|
+
# Print content normally - it scrolls in the top area
|
|
830
|
+
layout.print("Some output...")
|
|
831
|
+
|
|
832
|
+
# Get user input from fixed bottom
|
|
833
|
+
user_input = layout.get_input()
|
|
834
|
+
|
|
835
|
+
layout.stop()
|
|
836
|
+
"""
|
|
837
|
+
|
|
838
|
+
# Reserve lines at bottom for: status bar (1) + info line (1) + input prompt (1) + buffer (1)
|
|
839
|
+
BOTTOM_RESERVE = 5
|
|
840
|
+
|
|
841
|
+
def __init__(
|
|
842
|
+
self,
|
|
843
|
+
status_message: str = "Ready",
|
|
844
|
+
status_hint: str = "esc to cancel",
|
|
845
|
+
info_line: str = ""
|
|
846
|
+
):
|
|
847
|
+
self.status_message = status_message
|
|
848
|
+
self.status_hint = status_hint
|
|
849
|
+
self.info_line = info_line
|
|
850
|
+
self._status_bar: Optional[StatusBar] = None
|
|
851
|
+
self._terminal_height = 24
|
|
852
|
+
self._terminal_width = 80
|
|
853
|
+
self._active = False
|
|
854
|
+
self._lock = threading.Lock()
|
|
855
|
+
|
|
856
|
+
def _get_terminal_size(self) -> tuple:
|
|
857
|
+
"""Get terminal dimensions."""
|
|
858
|
+
try:
|
|
859
|
+
import shutil
|
|
860
|
+
size = shutil.get_terminal_size()
|
|
861
|
+
return size.lines, size.columns
|
|
862
|
+
except Exception:
|
|
863
|
+
return 24, 80
|
|
864
|
+
|
|
865
|
+
def _setup_scroll_region(self):
|
|
866
|
+
"""Setup ANSI scroll region (top portion of screen)."""
|
|
867
|
+
self._terminal_height, self._terminal_width = self._get_terminal_size()
|
|
868
|
+
scroll_bottom = self._terminal_height - self.BOTTOM_RESERVE
|
|
869
|
+
|
|
870
|
+
# Set scroll region: ESC[<top>;<bottom>r
|
|
871
|
+
# This makes only lines 1 to scroll_bottom scrollable
|
|
872
|
+
sys.stdout.write(f"\033[1;{scroll_bottom}r")
|
|
873
|
+
|
|
874
|
+
# Move cursor to top of scroll region
|
|
875
|
+
sys.stdout.write("\033[1;1H")
|
|
876
|
+
sys.stdout.flush()
|
|
877
|
+
|
|
878
|
+
def _draw_fixed_bottom(self):
|
|
879
|
+
"""Draw the fixed bottom area (status bar, info, input prompt)."""
|
|
880
|
+
height, width = self._terminal_height, self._terminal_width
|
|
881
|
+
bottom_start = height - self.BOTTOM_RESERVE + 1
|
|
882
|
+
|
|
883
|
+
# Save cursor position
|
|
884
|
+
sys.stdout.write("\0337")
|
|
885
|
+
|
|
886
|
+
# Move to bottom area (outside scroll region)
|
|
887
|
+
sys.stdout.write(f"\033[{bottom_start};1H")
|
|
888
|
+
|
|
889
|
+
# Clear the bottom lines
|
|
890
|
+
for i in range(self.BOTTOM_RESERVE):
|
|
891
|
+
sys.stdout.write(f"\033[{bottom_start + i};1H\033[K")
|
|
892
|
+
|
|
893
|
+
# Draw separator line
|
|
894
|
+
sys.stdout.write(f"\033[{bottom_start};1H")
|
|
895
|
+
separator = f"{Theme.BORDER}{'─' * (width - 1)}{Theme.RESET}"
|
|
896
|
+
sys.stdout.write(separator)
|
|
897
|
+
|
|
898
|
+
# Draw info line (if any)
|
|
899
|
+
if self.info_line:
|
|
900
|
+
sys.stdout.write(f"\033[{bottom_start + 1};1H")
|
|
901
|
+
sys.stdout.write(f"{Theme.MUTED}{self.info_line}{Theme.RESET}")
|
|
902
|
+
|
|
903
|
+
# Status bar will be drawn by StatusBar class at bottom_start + 2
|
|
904
|
+
|
|
905
|
+
# Input prompt position: bottom_start + 3
|
|
906
|
+
sys.stdout.write(f"\033[{bottom_start + 3};1H")
|
|
907
|
+
sys.stdout.write(f"{Theme.PRIMARY}>{Theme.RESET} ")
|
|
908
|
+
|
|
909
|
+
# Restore cursor to scroll region
|
|
910
|
+
sys.stdout.write("\0338")
|
|
911
|
+
sys.stdout.flush()
|
|
912
|
+
|
|
913
|
+
def start(self):
|
|
914
|
+
"""Initialize the fixed layout."""
|
|
915
|
+
self._active = True
|
|
916
|
+
self._setup_scroll_region()
|
|
917
|
+
|
|
918
|
+
# Clear screen in scroll region
|
|
919
|
+
sys.stdout.write("\033[2J\033[1;1H")
|
|
920
|
+
sys.stdout.flush()
|
|
921
|
+
|
|
922
|
+
# Draw fixed bottom
|
|
923
|
+
self._draw_fixed_bottom()
|
|
924
|
+
|
|
925
|
+
# Start status bar animation (positioned in fixed bottom area)
|
|
926
|
+
self._status_bar = StatusBar(
|
|
927
|
+
message=self.status_message,
|
|
928
|
+
hint=self.status_hint
|
|
929
|
+
)
|
|
930
|
+
# Don't auto-start - we'll render it manually in the fixed position
|
|
931
|
+
|
|
932
|
+
def print(self, text: str):
|
|
933
|
+
"""Print text to the scrollable area."""
|
|
934
|
+
if not self._active:
|
|
935
|
+
print(text)
|
|
936
|
+
return
|
|
937
|
+
|
|
938
|
+
with self._lock:
|
|
939
|
+
# Save cursor, move to scroll region, print, restore
|
|
940
|
+
sys.stdout.write("\0337")
|
|
941
|
+
# Text will print in scroll region and scroll naturally
|
|
942
|
+
print(text)
|
|
943
|
+
sys.stdout.write("\0338")
|
|
944
|
+
sys.stdout.flush()
|
|
945
|
+
|
|
946
|
+
def update_status(self, message: str):
|
|
947
|
+
"""Update the status bar message."""
|
|
948
|
+
with self._lock:
|
|
949
|
+
self.status_message = message
|
|
950
|
+
if self._status_bar:
|
|
951
|
+
self._status_bar.update_message(message)
|
|
952
|
+
|
|
953
|
+
def update_info(self, info: str):
|
|
954
|
+
"""Update the info line."""
|
|
955
|
+
with self._lock:
|
|
956
|
+
self.info_line = info
|
|
957
|
+
self._draw_fixed_bottom()
|
|
958
|
+
|
|
959
|
+
def stop(self):
|
|
960
|
+
"""Restore normal terminal mode."""
|
|
961
|
+
self._active = False
|
|
962
|
+
|
|
963
|
+
if self._status_bar:
|
|
964
|
+
self._status_bar.stop(clear=False)
|
|
965
|
+
self._status_bar = None
|
|
966
|
+
|
|
967
|
+
# Reset scroll region to full screen
|
|
968
|
+
sys.stdout.write("\033[r")
|
|
969
|
+
# Move cursor to bottom
|
|
970
|
+
sys.stdout.write(f"\033[{self._terminal_height};1H")
|
|
971
|
+
sys.stdout.flush()
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
class ConsoleUI:
|
|
975
|
+
"""Modern console UI renderer for Dolphin SDK"""
|
|
976
|
+
|
|
977
|
+
def __init__(self, style: Optional[BoxStyle] = None, verbose: bool = True):
|
|
978
|
+
self.style = style or BoxStyle()
|
|
979
|
+
self.verbose = verbose
|
|
980
|
+
self._current_spinner: Optional[Spinner] = None
|
|
981
|
+
self._active_skill_spinner: Optional[Spinner] = None # For skill call animations
|
|
982
|
+
self._paused_status_bar_for_skill: Optional['StatusBar'] = None # Track paused status bar for skill calls
|
|
983
|
+
self._status_bar: Optional[StatusBar] = None # For status bar animation
|
|
984
|
+
|
|
985
|
+
def show_status_bar(
|
|
986
|
+
self,
|
|
987
|
+
message: str = "Ready",
|
|
988
|
+
hint: str = "esc to cancel"
|
|
989
|
+
) -> StatusBar:
|
|
990
|
+
"""Show an animated status bar above the input prompt.
|
|
991
|
+
|
|
992
|
+
Args:
|
|
993
|
+
message: Status message to display
|
|
994
|
+
hint: Hint text (shown in parentheses)
|
|
995
|
+
|
|
996
|
+
Returns:
|
|
997
|
+
The StatusBar instance (can be used to update message or stop)
|
|
998
|
+
"""
|
|
999
|
+
# Stop any existing status bar first
|
|
1000
|
+
if self._status_bar:
|
|
1001
|
+
self._status_bar.stop()
|
|
1002
|
+
|
|
1003
|
+
self._status_bar = StatusBar(message=message, hint=hint)
|
|
1004
|
+
self._status_bar.start()
|
|
1005
|
+
return self._status_bar
|
|
1006
|
+
|
|
1007
|
+
def hide_status_bar(self):
|
|
1008
|
+
"""Hide the current status bar."""
|
|
1009
|
+
if self._status_bar:
|
|
1010
|
+
self._status_bar.stop()
|
|
1011
|
+
self._status_bar = None
|
|
1012
|
+
|
|
1013
|
+
def _get_terminal_width(self) -> int:
|
|
1014
|
+
"""Get terminal width, with fallback"""
|
|
1015
|
+
try:
|
|
1016
|
+
import shutil
|
|
1017
|
+
return shutil.get_terminal_size().columns
|
|
1018
|
+
except Exception:
|
|
1019
|
+
return 80
|
|
1020
|
+
|
|
1021
|
+
def _truncate(self, text: str, max_len: int) -> str:
|
|
1022
|
+
"""Truncate text with ellipsis"""
|
|
1023
|
+
if len(text) <= max_len:
|
|
1024
|
+
return text
|
|
1025
|
+
return text[:max_len - 3] + "..."
|
|
1026
|
+
|
|
1027
|
+
def _highlight_json(self, data: Any, indent: int = 0, max_depth: int = 3) -> str:
|
|
1028
|
+
"""Syntax highlight JSON data with colors"""
|
|
1029
|
+
if indent > max_depth:
|
|
1030
|
+
return f"{Theme.MUTED}...{Theme.RESET}"
|
|
1031
|
+
|
|
1032
|
+
spaces = " " * indent
|
|
1033
|
+
|
|
1034
|
+
if data is None:
|
|
1035
|
+
return f"{Theme.NULL_VALUE}null{Theme.RESET}"
|
|
1036
|
+
elif isinstance(data, bool):
|
|
1037
|
+
return f"{Theme.BOOLEAN_VALUE}{str(data).lower()}{Theme.RESET}"
|
|
1038
|
+
elif isinstance(data, (int, float)):
|
|
1039
|
+
return f"{Theme.NUMBER_VALUE}{data}{Theme.RESET}"
|
|
1040
|
+
elif isinstance(data, str):
|
|
1041
|
+
# Truncate long strings (60 chars max to prevent terminal line wrapping)
|
|
1042
|
+
display_str = self._truncate(data, 60)
|
|
1043
|
+
# Escape special characters
|
|
1044
|
+
escaped = display_str.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
|
1045
|
+
return f'{Theme.STRING_VALUE}"{escaped}"{Theme.RESET}'
|
|
1046
|
+
elif isinstance(data, list):
|
|
1047
|
+
if not data:
|
|
1048
|
+
return "[]"
|
|
1049
|
+
if len(data) == 1 and not isinstance(data[0], (dict, list)):
|
|
1050
|
+
return f"[{self._highlight_json(data[0], indent)}]"
|
|
1051
|
+
items = [self._highlight_json(item, indent + 1, max_depth) for item in data[:5]]
|
|
1052
|
+
if len(data) > 5:
|
|
1053
|
+
items.append(f"{Theme.MUTED}...+{len(data) - 5} more{Theme.RESET}")
|
|
1054
|
+
inner = f",\n{spaces} ".join(items)
|
|
1055
|
+
return f"[\n{spaces} {inner}\n{spaces}]"
|
|
1056
|
+
elif isinstance(data, dict):
|
|
1057
|
+
if not data:
|
|
1058
|
+
return "{}"
|
|
1059
|
+
items = []
|
|
1060
|
+
for i, (k, v) in enumerate(list(data.items())[:10]):
|
|
1061
|
+
key_str = f'{Theme.PARAM_KEY}"{k}"{Theme.RESET}'
|
|
1062
|
+
val_str = self._highlight_json(v, indent + 1, max_depth)
|
|
1063
|
+
items.append(f"{key_str}: {val_str}")
|
|
1064
|
+
if i >= 9 and len(data) > 10:
|
|
1065
|
+
items.append(f"{Theme.MUTED}...+{len(data) - 10} more{Theme.RESET}")
|
|
1066
|
+
break
|
|
1067
|
+
inner = f",\n{spaces} ".join(items)
|
|
1068
|
+
return f"{{\n{spaces} {inner}\n{spaces}}}"
|
|
1069
|
+
else:
|
|
1070
|
+
return f"{Theme.PARAM_VALUE}{str(data)}{Theme.RESET}"
|
|
1071
|
+
|
|
1072
|
+
def _format_compact_json(self, data: Any, max_width: int = 80) -> str:
|
|
1073
|
+
"""Format JSON compactly if possible, otherwise pretty print"""
|
|
1074
|
+
try:
|
|
1075
|
+
compact = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
|
1076
|
+
if len(compact) <= max_width:
|
|
1077
|
+
return self._highlight_json(data, 0, 1)
|
|
1078
|
+
return self._highlight_json(data, 0, 3)
|
|
1079
|
+
except Exception:
|
|
1080
|
+
return str(data)
|
|
1081
|
+
|
|
1082
|
+
def _draw_box_top(self, title: str = "", icon: str = "", status: StatusType = StatusType.RUNNING) -> str:
|
|
1083
|
+
"""Draw the top border of a box with optional title"""
|
|
1084
|
+
width = min(self.style.width, self._get_terminal_width() - 4)
|
|
1085
|
+
|
|
1086
|
+
# Status indicator
|
|
1087
|
+
status_indicators = {
|
|
1088
|
+
StatusType.PENDING: (Theme.MUTED, Theme.BOX_CIRCLE),
|
|
1089
|
+
StatusType.RUNNING: (Theme.PRIMARY, Theme.BOX_SPINNER_FRAMES[0]),
|
|
1090
|
+
StatusType.SUCCESS: (Theme.SUCCESS, Theme.BOX_CHECK),
|
|
1091
|
+
StatusType.ERROR: (Theme.ERROR, Theme.BOX_CROSS),
|
|
1092
|
+
StatusType.SKIPPED: (Theme.WARNING, "○"),
|
|
1093
|
+
}
|
|
1094
|
+
status_color, status_char = status_indicators.get(status, (Theme.MUTED, "○"))
|
|
1095
|
+
|
|
1096
|
+
# Build title section
|
|
1097
|
+
if title:
|
|
1098
|
+
# Tool name in ITALIC
|
|
1099
|
+
title_section = f" {status_color}{status_char}{Theme.RESET} {Theme.BOLD}{Theme.ITALIC}{icon}{Theme.TOOL_NAME}{title}{Theme.RESET} "
|
|
1100
|
+
title_len = len(f" {status_char} {icon}{title} ")
|
|
1101
|
+
else:
|
|
1102
|
+
title_section = ""
|
|
1103
|
+
title_len = 0
|
|
1104
|
+
|
|
1105
|
+
# Calculate remaining width for border
|
|
1106
|
+
remaining = width - title_len - 2 # 2 for corners
|
|
1107
|
+
left_border = Theme.BOX_HORIZONTAL * 1
|
|
1108
|
+
right_border = Theme.BOX_HORIZONTAL * max(0, remaining - 1)
|
|
1109
|
+
|
|
1110
|
+
return (
|
|
1111
|
+
f"{Theme.BORDER_ACCENT}{Theme.BOX_TOP_LEFT}{left_border}{Theme.RESET}"
|
|
1112
|
+
f"{title_section}"
|
|
1113
|
+
f"{Theme.BORDER}{right_border}{Theme.BOX_TOP_RIGHT}{Theme.RESET}"
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
def _draw_box_bottom(self, status_text: str = "") -> str:
|
|
1117
|
+
"""Draw the bottom border of a box with optional status"""
|
|
1118
|
+
width = min(self.style.width, self._get_terminal_width() - 4)
|
|
1119
|
+
|
|
1120
|
+
if status_text:
|
|
1121
|
+
status_section = f" {Theme.MUTED}{status_text}{Theme.RESET} "
|
|
1122
|
+
status_len = len(f" {status_text} ")
|
|
1123
|
+
else:
|
|
1124
|
+
status_section = ""
|
|
1125
|
+
status_len = 0
|
|
1126
|
+
|
|
1127
|
+
remaining = width - status_len - 2
|
|
1128
|
+
left_border = Theme.BOX_HORIZONTAL * max(0, remaining // 2)
|
|
1129
|
+
right_border = Theme.BOX_HORIZONTAL * max(0, remaining - len(left_border))
|
|
1130
|
+
|
|
1131
|
+
return (
|
|
1132
|
+
f"{Theme.BORDER}{Theme.BOX_BOTTOM_LEFT}{left_border}{Theme.RESET}"
|
|
1133
|
+
f"{status_section}"
|
|
1134
|
+
f"{Theme.BORDER}{right_border}{Theme.BOX_BOTTOM_RIGHT}{Theme.RESET}"
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
def _draw_box_line(self, content: str, prefix: str = "") -> str:
|
|
1138
|
+
"""Draw a line of content inside a box"""
|
|
1139
|
+
if prefix:
|
|
1140
|
+
return f"{Theme.BORDER}{Theme.BOX_VERTICAL}{Theme.RESET} {prefix}{content}"
|
|
1141
|
+
return f"{Theme.BORDER}{Theme.BOX_VERTICAL}{Theme.RESET} {content}"
|
|
1142
|
+
|
|
1143
|
+
# ─────────────────────────────────────────────────────────────
|
|
1144
|
+
# Collapsible Output Configuration
|
|
1145
|
+
# ─────────────────────────────────────────────────────────────
|
|
1146
|
+
# Default threshold: collapse if output exceeds this many lines
|
|
1147
|
+
COLLAPSE_THRESHOLD_LINES = 12
|
|
1148
|
+
# Number of lines to show at the beginning when collapsed
|
|
1149
|
+
COLLAPSE_HEAD_LINES = 6
|
|
1150
|
+
# Number of lines to show at the end when collapsed
|
|
1151
|
+
COLLAPSE_TAIL_LINES = 4
|
|
1152
|
+
|
|
1153
|
+
@staticmethod
|
|
1154
|
+
def _strip_ansi(text: str) -> str:
|
|
1155
|
+
"""Remove ANSI escape codes from text."""
|
|
1156
|
+
import re
|
|
1157
|
+
return re.sub(r'\x1b\[[0-9;]*m', '', text)
|
|
1158
|
+
|
|
1159
|
+
def _format_hidden_lines_hint(self, count: int, prefix: str = "") -> str:
|
|
1160
|
+
"""Format the 'hidden lines' indicator consistently.
|
|
1161
|
+
|
|
1162
|
+
Args:
|
|
1163
|
+
count: Number of hidden lines
|
|
1164
|
+
prefix: Optional prefix (e.g., "┆ " or "│ ")
|
|
1165
|
+
|
|
1166
|
+
Returns:
|
|
1167
|
+
Formatted string like "... (6 more lines)"
|
|
1168
|
+
"""
|
|
1169
|
+
return f"{Theme.MUTED}{prefix}... ({count} more lines){Theme.RESET}"
|
|
1170
|
+
|
|
1171
|
+
def _format_collapsed_content(
|
|
1172
|
+
self,
|
|
1173
|
+
content: str,
|
|
1174
|
+
threshold: int = None,
|
|
1175
|
+
head_lines: int = None,
|
|
1176
|
+
tail_lines: int = None,
|
|
1177
|
+
collapse_indicator: str = "┆"
|
|
1178
|
+
) -> tuple[str, bool, int]:
|
|
1179
|
+
"""
|
|
1180
|
+
Format content with automatic collapsing for long outputs.
|
|
1181
|
+
|
|
1182
|
+
If content exceeds threshold lines, shows only head and tail lines
|
|
1183
|
+
with a collapse indicator in between.
|
|
1184
|
+
|
|
1185
|
+
Args:
|
|
1186
|
+
content: The content string to format
|
|
1187
|
+
threshold: Lines threshold to trigger collapse (default: COLLAPSE_THRESHOLD_LINES)
|
|
1188
|
+
head_lines: Number of lines to show at start (default: COLLAPSE_HEAD_LINES)
|
|
1189
|
+
tail_lines: Number of lines to show at end (default: COLLAPSE_TAIL_LINES)
|
|
1190
|
+
collapse_indicator: Character to prefix collapsed preview lines
|
|
1191
|
+
|
|
1192
|
+
Returns:
|
|
1193
|
+
Tuple of (formatted_content, is_collapsed, total_lines)
|
|
1194
|
+
"""
|
|
1195
|
+
threshold = threshold or self.COLLAPSE_THRESHOLD_LINES
|
|
1196
|
+
head_lines = head_lines or self.COLLAPSE_HEAD_LINES
|
|
1197
|
+
tail_lines = tail_lines or self.COLLAPSE_TAIL_LINES
|
|
1198
|
+
|
|
1199
|
+
lines = content.split('\n')
|
|
1200
|
+
|
|
1201
|
+
# Filter out consecutive empty lines (lines with only ANSI codes count as empty)
|
|
1202
|
+
filtered_lines = []
|
|
1203
|
+
prev_was_blank = False
|
|
1204
|
+
for line in lines:
|
|
1205
|
+
is_blank = not self._strip_ansi(line).strip()
|
|
1206
|
+
if is_blank:
|
|
1207
|
+
if not prev_was_blank:
|
|
1208
|
+
filtered_lines.append(line)
|
|
1209
|
+
prev_was_blank = True
|
|
1210
|
+
else:
|
|
1211
|
+
filtered_lines.append(line)
|
|
1212
|
+
prev_was_blank = False
|
|
1213
|
+
lines = filtered_lines
|
|
1214
|
+
total_lines = len(lines)
|
|
1215
|
+
|
|
1216
|
+
# If within threshold, return filtered content
|
|
1217
|
+
if total_lines <= threshold:
|
|
1218
|
+
return '\n'.join(lines), False, total_lines
|
|
1219
|
+
|
|
1220
|
+
# Filter out Session meta-info lines for head display
|
|
1221
|
+
# These lines are not meaningful to users
|
|
1222
|
+
meta_patterns = ('Session restored:', '[Session ', 'Session ')
|
|
1223
|
+
|
|
1224
|
+
def is_meta_line(line: str) -> bool:
|
|
1225
|
+
"""Check if a line is meta-info that should be skipped."""
|
|
1226
|
+
stripped = line.strip()
|
|
1227
|
+
if not stripped: # Empty lines are not meta
|
|
1228
|
+
return False
|
|
1229
|
+
return any(p in stripped for p in meta_patterns)
|
|
1230
|
+
|
|
1231
|
+
# Find first meaningful line (skip meta-info at the beginning)
|
|
1232
|
+
meaningful_start = 0
|
|
1233
|
+
for i, line in enumerate(lines):
|
|
1234
|
+
if not is_meta_line(line):
|
|
1235
|
+
meaningful_start = i
|
|
1236
|
+
break
|
|
1237
|
+
|
|
1238
|
+
# Collapse: show head + indicator + tail
|
|
1239
|
+
collapsed_lines = []
|
|
1240
|
+
|
|
1241
|
+
# Head lines: prefer meaningful content over meta-info
|
|
1242
|
+
head_source = lines[meaningful_start:meaningful_start + head_lines]
|
|
1243
|
+
if len(head_source) < head_lines:
|
|
1244
|
+
# Not enough meaningful lines, fall back to original
|
|
1245
|
+
head_source = lines[:head_lines]
|
|
1246
|
+
|
|
1247
|
+
for line in head_source:
|
|
1248
|
+
# Truncate very long lines
|
|
1249
|
+
if len(line) > 78:
|
|
1250
|
+
line = line[:75] + "..."
|
|
1251
|
+
collapsed_lines.append(f"{Theme.MUTED}{collapse_indicator}{Theme.RESET} {line}")
|
|
1252
|
+
|
|
1253
|
+
# Collapse indicator showing hidden lines count
|
|
1254
|
+
hidden_count = total_lines - head_lines - tail_lines
|
|
1255
|
+
collapsed_lines.append(
|
|
1256
|
+
self._format_hidden_lines_hint(hidden_count, prefix=f"{collapse_indicator} ")
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
# Tail lines with collapse indicator
|
|
1260
|
+
for line in lines[-tail_lines:]:
|
|
1261
|
+
if len(line) > 78:
|
|
1262
|
+
line = line[:75] + "..."
|
|
1263
|
+
collapsed_lines.append(f"{Theme.MUTED}{collapse_indicator}{Theme.RESET} {line}")
|
|
1264
|
+
|
|
1265
|
+
return '\n'.join(collapsed_lines), True, total_lines
|
|
1266
|
+
|
|
1267
|
+
def _draw_separator(self, style: str = "light") -> str:
|
|
1268
|
+
"""Draw a horizontal separator"""
|
|
1269
|
+
width = min(self.style.width, self._get_terminal_width() - 4)
|
|
1270
|
+
if style == "light":
|
|
1271
|
+
return f"{Theme.MUTED}{'·' * (width - 2)}{Theme.RESET}"
|
|
1272
|
+
elif style == "dashed":
|
|
1273
|
+
return f"{Theme.BORDER}{'╌' * (width - 2)}{Theme.RESET}"
|
|
1274
|
+
else:
|
|
1275
|
+
return f"{Theme.BORDER}{Theme.BOX_HORIZONTAL * (width - 2)}{Theme.RESET}"
|
|
1276
|
+
|
|
1277
|
+
def _format_params_clean(self, params: Dict[str, Any], max_val_len: int = 500) -> str:
|
|
1278
|
+
"""
|
|
1279
|
+
Format parameters as a clean, aligned key-value list (YAML-style).
|
|
1280
|
+
|
|
1281
|
+
This provides a much more readable output than raw JSON, especially
|
|
1282
|
+
for agents with long text inputs (reflections, plans, etc).
|
|
1283
|
+
|
|
1284
|
+
Special handling for code-like parameters (cmd, code, script) which
|
|
1285
|
+
are displayed as properly formatted code blocks.
|
|
1286
|
+
"""
|
|
1287
|
+
if not params:
|
|
1288
|
+
return f"{Theme.MUTED}(no parameters){Theme.RESET}"
|
|
1289
|
+
|
|
1290
|
+
# Keys that should be treated as code blocks
|
|
1291
|
+
CODE_KEYS = {'cmd', 'code', 'script', 'python_code', 'shell_code', 'command'}
|
|
1292
|
+
|
|
1293
|
+
lines = []
|
|
1294
|
+
# Calculate alignment
|
|
1295
|
+
keys = list(params.keys())
|
|
1296
|
+
max_key_len = max(len(str(k)) for k in keys) if keys else 0
|
|
1297
|
+
max_key_len = min(max_key_len, 25) # Cap alignment padding to avoid huge gaps
|
|
1298
|
+
|
|
1299
|
+
# Check if we should truncate the number of items
|
|
1300
|
+
items = list(params.items())
|
|
1301
|
+
total_items = len(items)
|
|
1302
|
+
truncated_items = False
|
|
1303
|
+
|
|
1304
|
+
# If parameters are huge, limit the number of items displayed
|
|
1305
|
+
if total_items > 15:
|
|
1306
|
+
items = items[:15]
|
|
1307
|
+
truncated_items = True
|
|
1308
|
+
|
|
1309
|
+
for k, v in items:
|
|
1310
|
+
key_str = str(k)
|
|
1311
|
+
key_lower = key_str.lower()
|
|
1312
|
+
|
|
1313
|
+
# Alignment padding
|
|
1314
|
+
if len(key_str) > max_key_len:
|
|
1315
|
+
padding = " "
|
|
1316
|
+
display_key = self._truncate(key_str, max_key_len)
|
|
1317
|
+
else:
|
|
1318
|
+
padding = " " * (max_key_len - len(key_str))
|
|
1319
|
+
display_key = key_str
|
|
1320
|
+
|
|
1321
|
+
# Check if this is a code-like parameter
|
|
1322
|
+
if key_lower in CODE_KEYS and isinstance(v, str) and '\n' in v:
|
|
1323
|
+
# Format as code block
|
|
1324
|
+
lines.append(f" {Theme.PARAM_KEY}{display_key}{Theme.RESET}:{padding}")
|
|
1325
|
+
code_lines = v.split('\n')
|
|
1326
|
+
# Limit to 10 lines max for display
|
|
1327
|
+
for i, code_line in enumerate(code_lines[:10]):
|
|
1328
|
+
# Truncate very long lines
|
|
1329
|
+
if len(code_line) > 80:
|
|
1330
|
+
code_line = code_line[:77] + "..."
|
|
1331
|
+
lines.append(f" {Theme.MUTED}│{Theme.RESET} {Theme.STRING_VALUE}{code_line}{Theme.RESET}")
|
|
1332
|
+
if len(code_lines) > 10:
|
|
1333
|
+
lines.append(f" {self._format_hidden_lines_hint(len(code_lines) - 10, prefix='│ ')}")
|
|
1334
|
+
elif isinstance(v, str):
|
|
1335
|
+
# Regular string - escape newlines for single-line display
|
|
1336
|
+
val_str = v.replace("\n", "\\n")
|
|
1337
|
+
if len(val_str) > max_val_len:
|
|
1338
|
+
val_str = val_str[:max_val_len-3] + "..."
|
|
1339
|
+
val_display = f"{Theme.STRING_VALUE}{val_str}{Theme.RESET}"
|
|
1340
|
+
lines.append(f" {Theme.PARAM_KEY}{display_key}{Theme.RESET}:{padding} {val_display}")
|
|
1341
|
+
elif isinstance(v, (int, float)):
|
|
1342
|
+
val_display = f"{Theme.NUMBER_VALUE}{v}{Theme.RESET}"
|
|
1343
|
+
lines.append(f" {Theme.PARAM_KEY}{display_key}{Theme.RESET}:{padding} {val_display}")
|
|
1344
|
+
elif isinstance(v, bool):
|
|
1345
|
+
val_display = f"{Theme.BOOLEAN_VALUE}{str(v).lower()}{Theme.RESET}"
|
|
1346
|
+
lines.append(f" {Theme.PARAM_KEY}{display_key}{Theme.RESET}:{padding} {val_display}")
|
|
1347
|
+
elif v is None:
|
|
1348
|
+
val_display = f"{Theme.NULL_VALUE}null{Theme.RESET}"
|
|
1349
|
+
lines.append(f" {Theme.PARAM_KEY}{display_key}{Theme.RESET}:{padding} {val_display}")
|
|
1350
|
+
else:
|
|
1351
|
+
# Complex types: use existing json highlighter
|
|
1352
|
+
val_display = self._highlight_json(v, indent=0, max_depth=1)
|
|
1353
|
+
lines.append(f" {Theme.PARAM_KEY}{display_key}{Theme.RESET}:{padding} {val_display}")
|
|
1354
|
+
|
|
1355
|
+
if truncated_items:
|
|
1356
|
+
lines.append(f"{Theme.MUTED} ...+{total_items - 15} more parameters{Theme.RESET}")
|
|
1357
|
+
|
|
1358
|
+
return "\n".join(lines)
|
|
1359
|
+
|
|
1360
|
+
# ─────────────────────────────────────────────────────────────
|
|
1361
|
+
# Public API - Skill/Tool Call Display
|
|
1362
|
+
# ─────────────────────────────────────────────────────────────
|
|
1363
|
+
def skill_call_start(
|
|
1364
|
+
self,
|
|
1365
|
+
skill_name: str,
|
|
1366
|
+
params: Dict[str, Any],
|
|
1367
|
+
max_param_length: int = 300,
|
|
1368
|
+
verbose: Optional[bool] = None
|
|
1369
|
+
) -> None:
|
|
1370
|
+
"""
|
|
1371
|
+
Display the start of a skill/tool call with modern styling.
|
|
1372
|
+
|
|
1373
|
+
Inspired by Codex CLI's bordered tool call display and
|
|
1374
|
+
Claude Code's clean parameter formatting.
|
|
1375
|
+
|
|
1376
|
+
Now includes an animated spinner while the skill is running.
|
|
1377
|
+
|
|
1378
|
+
Args:
|
|
1379
|
+
skill_name: Name of the skill being called
|
|
1380
|
+
params: Parameters passed to the skill
|
|
1381
|
+
max_param_length: Maximum length for parameter display
|
|
1382
|
+
verbose: Override verbose setting
|
|
1383
|
+
"""
|
|
1384
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
1385
|
+
return
|
|
1386
|
+
|
|
1387
|
+
# Stop any existing spinner first
|
|
1388
|
+
if self._active_skill_spinner:
|
|
1389
|
+
self._active_skill_spinner.stop()
|
|
1390
|
+
self._active_skill_spinner = None
|
|
1391
|
+
|
|
1392
|
+
# Format the parameters using the clean style
|
|
1393
|
+
if params:
|
|
1394
|
+
formatted_params = self._format_params_clean(params, max_val_len=max_param_length)
|
|
1395
|
+
else:
|
|
1396
|
+
formatted_params = f"{Theme.MUTED}(no parameters){Theme.RESET}"
|
|
1397
|
+
|
|
1398
|
+
# Build the output
|
|
1399
|
+
output_lines = [
|
|
1400
|
+
self._draw_box_top(skill_name, "⚡ ", StatusType.RUNNING),
|
|
1401
|
+
# Input label: Bold, no colon
|
|
1402
|
+
self._draw_box_line(f"{Theme.BOLD}{Theme.LABEL}Input{Theme.RESET}"),
|
|
1403
|
+
]
|
|
1404
|
+
|
|
1405
|
+
# Add formatted parameters with proper indentation
|
|
1406
|
+
for line in formatted_params.split("\n"):
|
|
1407
|
+
output_lines.append(self._draw_box_line(f" {line}"))
|
|
1408
|
+
|
|
1409
|
+
# Print all lines
|
|
1410
|
+
for line in output_lines:
|
|
1411
|
+
print(line)
|
|
1412
|
+
|
|
1413
|
+
# Start the spinner animation for skill execution
|
|
1414
|
+
# Also animate the icon in the Box Header (top-left)
|
|
1415
|
+
# Header is at index 1 of output_lines
|
|
1416
|
+
# Cursor is currently below the last line
|
|
1417
|
+
# Distance to Header = len(output_lines) - 1
|
|
1418
|
+
header_up_dist = len(output_lines) - 1
|
|
1419
|
+
|
|
1420
|
+
self._active_skill_spinner = Spinner(
|
|
1421
|
+
f"Running {skill_name}...",
|
|
1422
|
+
position_updates=[{'up': header_up_dist, 'col': 4}]
|
|
1423
|
+
)
|
|
1424
|
+
# Note: Fixed-position StatusBar uses cursor save/restore, so it won't conflict
|
|
1425
|
+
# with the skill spinner. No need to pause StatusBar here.
|
|
1426
|
+
self._active_skill_spinner.start()
|
|
1427
|
+
|
|
1428
|
+
def _try_parse_json(self, text: str) -> Optional[Any]:
|
|
1429
|
+
"""Try to parse a string as JSON or Python literal, return None if it fails."""
|
|
1430
|
+
if not text or not isinstance(text, str):
|
|
1431
|
+
return None
|
|
1432
|
+
text = text.strip()
|
|
1433
|
+
# Quick check: must start with { or [
|
|
1434
|
+
if not (text.startswith('{') or text.startswith('[')):
|
|
1435
|
+
return None
|
|
1436
|
+
|
|
1437
|
+
# 1. Try JSON first (standard)
|
|
1438
|
+
try:
|
|
1439
|
+
return json.loads(text)
|
|
1440
|
+
except (json.JSONDecodeError, ValueError):
|
|
1441
|
+
pass
|
|
1442
|
+
|
|
1443
|
+
# 2. Try ast.literal_eval (for Python stringified objects with single quotes)
|
|
1444
|
+
try:
|
|
1445
|
+
val = ast.literal_eval(text)
|
|
1446
|
+
if isinstance(val, (dict, list)):
|
|
1447
|
+
return val
|
|
1448
|
+
except (ValueError, SyntaxError):
|
|
1449
|
+
pass
|
|
1450
|
+
|
|
1451
|
+
return None
|
|
1452
|
+
|
|
1453
|
+
def _format_response(self, response: Any, max_length: int = 300) -> str:
|
|
1454
|
+
"""
|
|
1455
|
+
Format a response for display, with intelligent type detection.
|
|
1456
|
+
|
|
1457
|
+
- If response is dict/list, format as highlighted JSON
|
|
1458
|
+
- If response is a string that looks like JSON, parse and format it
|
|
1459
|
+
- Otherwise, format as plain text with proper truncation
|
|
1460
|
+
"""
|
|
1461
|
+
# Already a dict or list - use JSON formatting
|
|
1462
|
+
if isinstance(response, (dict, list)):
|
|
1463
|
+
return self._format_compact_json(response)
|
|
1464
|
+
|
|
1465
|
+
# String - try to parse as JSON first
|
|
1466
|
+
if isinstance(response, str):
|
|
1467
|
+
# Try to parse as JSON
|
|
1468
|
+
parsed = self._try_parse_json(response)
|
|
1469
|
+
if parsed is not None:
|
|
1470
|
+
return self._format_compact_json(parsed)
|
|
1471
|
+
|
|
1472
|
+
# Check if it's multiline text (like markdown output)
|
|
1473
|
+
if '\n' in response:
|
|
1474
|
+
return self._format_multiline_text(response, max_length)
|
|
1475
|
+
|
|
1476
|
+
# Simple string - truncate and colorize
|
|
1477
|
+
display = self._truncate(response, max_length)
|
|
1478
|
+
return f"{Theme.PARAM_VALUE}{display}{Theme.RESET}"
|
|
1479
|
+
|
|
1480
|
+
# Other types - convert to string
|
|
1481
|
+
return f"{Theme.PARAM_VALUE}{str(response)[:max_length]}{Theme.RESET}"
|
|
1482
|
+
|
|
1483
|
+
def _format_multiline_text(self, text: str, max_length: int = 300) -> str:
|
|
1484
|
+
"""
|
|
1485
|
+
Format multiline text for display in a box.
|
|
1486
|
+
Handles:
|
|
1487
|
+
- Markdown-like content
|
|
1488
|
+
- Python execution output (Session info, errors, tracebacks)
|
|
1489
|
+
- General structured text
|
|
1490
|
+
"""
|
|
1491
|
+
lines = text.split('\n')
|
|
1492
|
+
|
|
1493
|
+
# Filter out consecutive empty lines
|
|
1494
|
+
filtered_lines = []
|
|
1495
|
+
prev_was_blank = False
|
|
1496
|
+
for line in lines:
|
|
1497
|
+
is_blank = not line.strip()
|
|
1498
|
+
if is_blank:
|
|
1499
|
+
if not prev_was_blank:
|
|
1500
|
+
filtered_lines.append(line)
|
|
1501
|
+
prev_was_blank = True
|
|
1502
|
+
else:
|
|
1503
|
+
filtered_lines.append(line)
|
|
1504
|
+
prev_was_blank = False
|
|
1505
|
+
lines = filtered_lines
|
|
1506
|
+
|
|
1507
|
+
# Filter out meta-info lines that are not useful for display
|
|
1508
|
+
meta_patterns = (
|
|
1509
|
+
'<class ', # e.g., <class 'pandas.core.frame.DataFrame'>
|
|
1510
|
+
'Session restored:', # Session restore info
|
|
1511
|
+
'[Session ', # Session execution info
|
|
1512
|
+
)
|
|
1513
|
+
lines = [line for line in lines if not any(p in line for p in meta_patterns)]
|
|
1514
|
+
|
|
1515
|
+
result_lines = []
|
|
1516
|
+
in_traceback = False
|
|
1517
|
+
|
|
1518
|
+
# NOTE: No max_lines limit here - let _format_collapsed_content handle folding
|
|
1519
|
+
for i, line in enumerate(lines):
|
|
1520
|
+
# Truncate long lines
|
|
1521
|
+
display_line = line[:77] + "..." if len(line) > 80 else line
|
|
1522
|
+
|
|
1523
|
+
# Handle Traceback state
|
|
1524
|
+
if line.startswith('Traceback') or line.startswith('错误:'):
|
|
1525
|
+
in_traceback = True
|
|
1526
|
+
result_lines.append(f"{Theme.ERROR}{Theme.BOLD}{display_line}{Theme.RESET}")
|
|
1527
|
+
continue
|
|
1528
|
+
|
|
1529
|
+
if in_traceback:
|
|
1530
|
+
# Check for exit conditions (new section headers)
|
|
1531
|
+
if line.startswith('Output:') or line.startswith('输出:') or \
|
|
1532
|
+
line.startswith('Return value:') or line.startswith('返回值:') or \
|
|
1533
|
+
line.startswith('Session ') or line.startswith('[Session'):
|
|
1534
|
+
in_traceback = False
|
|
1535
|
+
else:
|
|
1536
|
+
# In traceback processing
|
|
1537
|
+
if line.startswith(' File ') or line.strip().startswith('File '):
|
|
1538
|
+
result_lines.append(f"{Theme.WARNING}{display_line}{Theme.RESET}")
|
|
1539
|
+
elif 'Error:' in line or 'Exception:' in line or line.endswith('Error'):
|
|
1540
|
+
# This usually marks the end of a traceback block
|
|
1541
|
+
result_lines.append(f"{Theme.ERROR}{Theme.BOLD}{display_line}{Theme.RESET}")
|
|
1542
|
+
in_traceback = False
|
|
1543
|
+
else:
|
|
1544
|
+
result_lines.append(f"{Theme.MUTED}{display_line}{Theme.RESET}")
|
|
1545
|
+
continue
|
|
1546
|
+
|
|
1547
|
+
# Standard processing (non-traceback)
|
|
1548
|
+
if line.startswith('Error ') or 'Error:' in line or 'error:' in line.lower():
|
|
1549
|
+
result_lines.append(f"{Theme.ERROR}{display_line}{Theme.RESET}")
|
|
1550
|
+
elif line.startswith('输出:') or line.startswith('Output:'):
|
|
1551
|
+
result_lines.append(f"{Theme.SUCCESS}{display_line}{Theme.RESET}")
|
|
1552
|
+
elif line.startswith('Return value:') or line.startswith('返回值:'):
|
|
1553
|
+
result_lines.append(f"{Theme.PRIMARY}{Theme.BOLD}{display_line}{Theme.RESET}")
|
|
1554
|
+
elif line.startswith('[Session') or line.startswith('Session '):
|
|
1555
|
+
result_lines.append(f"{Theme.MUTED}{display_line}{Theme.RESET}")
|
|
1556
|
+
|
|
1557
|
+
# Markdown patterns
|
|
1558
|
+
elif line.startswith('# '):
|
|
1559
|
+
result_lines.append(f"{Theme.PRIMARY}{Theme.BOLD}{display_line}{Theme.RESET}")
|
|
1560
|
+
elif line.startswith('## '):
|
|
1561
|
+
result_lines.append(f"{Theme.SECONDARY}{Theme.BOLD}{display_line}{Theme.RESET}")
|
|
1562
|
+
elif line.startswith('### '):
|
|
1563
|
+
result_lines.append(f"{Theme.ACCENT}{display_line}{Theme.RESET}")
|
|
1564
|
+
elif line.startswith('- ') or line.startswith('* '):
|
|
1565
|
+
result_lines.append(f"{Theme.SUCCESS}•{Theme.RESET} {display_line[2:]}")
|
|
1566
|
+
elif line.startswith(' - ') or line.startswith(' * '):
|
|
1567
|
+
result_lines.append(f" {Theme.SUCCESS}◦{Theme.RESET} {display_line[4:]}")
|
|
1568
|
+
elif line.strip().startswith('|'):
|
|
1569
|
+
result_lines.append(f"{Theme.MUTED}{display_line}{Theme.RESET}")
|
|
1570
|
+
else:
|
|
1571
|
+
result_lines.append(f"{Theme.PARAM_VALUE}{display_line}{Theme.RESET}")
|
|
1572
|
+
|
|
1573
|
+
# NOTE: No max_length truncation here - let _format_collapsed_content handle folding
|
|
1574
|
+
return '\n'.join(result_lines)
|
|
1575
|
+
|
|
1576
|
+
def skill_call_end(
|
|
1577
|
+
self,
|
|
1578
|
+
skill_name: str,
|
|
1579
|
+
response: Any,
|
|
1580
|
+
max_response_length: int = 300,
|
|
1581
|
+
success: bool = True,
|
|
1582
|
+
duration_ms: Optional[float] = None,
|
|
1583
|
+
verbose: Optional[bool] = None,
|
|
1584
|
+
collapsed: bool = True
|
|
1585
|
+
) -> None:
|
|
1586
|
+
"""
|
|
1587
|
+
Display the completion of a skill/tool call.
|
|
1588
|
+
|
|
1589
|
+
Stops the running spinner animation before showing results.
|
|
1590
|
+
Supports automatic collapsing of long outputs.
|
|
1591
|
+
|
|
1592
|
+
Args:
|
|
1593
|
+
skill_name: Name of the skill that completed
|
|
1594
|
+
response: Response from the skill
|
|
1595
|
+
max_response_length: Maximum length for response display
|
|
1596
|
+
success: Whether the call was successful
|
|
1597
|
+
duration_ms: Execution duration in milliseconds
|
|
1598
|
+
verbose: Override verbose setting
|
|
1599
|
+
collapsed: Whether to collapse long outputs (default: True)
|
|
1600
|
+
"""
|
|
1601
|
+
# Stop spinner first (before any output) and update header icon
|
|
1602
|
+
if self._active_skill_spinner:
|
|
1603
|
+
self._active_skill_spinner.stop(success=success)
|
|
1604
|
+
self._active_skill_spinner = None
|
|
1605
|
+
|
|
1606
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
1607
|
+
return
|
|
1608
|
+
|
|
1609
|
+
# Format the response using intelligent type detection
|
|
1610
|
+
formatted_response = self._format_response(response, max_response_length)
|
|
1611
|
+
|
|
1612
|
+
# Skip rendering if response is empty (avoid empty boxes)
|
|
1613
|
+
stripped_response = self._strip_ansi(formatted_response).strip()
|
|
1614
|
+
if not stripped_response:
|
|
1615
|
+
return
|
|
1616
|
+
|
|
1617
|
+
# Apply collapsing if enabled
|
|
1618
|
+
is_collapsed = False
|
|
1619
|
+
total_lines = 0
|
|
1620
|
+
if collapsed:
|
|
1621
|
+
formatted_response, is_collapsed, total_lines = self._format_collapsed_content(
|
|
1622
|
+
formatted_response
|
|
1623
|
+
)
|
|
1624
|
+
else:
|
|
1625
|
+
total_lines = len(formatted_response.split('\n'))
|
|
1626
|
+
|
|
1627
|
+
# Build status text
|
|
1628
|
+
status_parts = []
|
|
1629
|
+
if duration_ms is not None:
|
|
1630
|
+
if duration_ms >= 1000:
|
|
1631
|
+
status_parts.append(f"{duration_ms/1000:.2f}s")
|
|
1632
|
+
else:
|
|
1633
|
+
status_parts.append(f"{duration_ms:.0f}ms")
|
|
1634
|
+
status_text = " ".join(status_parts) if status_parts else ""
|
|
1635
|
+
|
|
1636
|
+
# Build output label
|
|
1637
|
+
status_icon = Theme.BOX_CHECK if success else Theme.BOX_CROSS
|
|
1638
|
+
status_color = Theme.SUCCESS if success else Theme.ERROR
|
|
1639
|
+
|
|
1640
|
+
output_label = (
|
|
1641
|
+
f"{Theme.BOLD}{Theme.LABEL}Output{Theme.RESET} "
|
|
1642
|
+
f"{status_color}{status_icon}{Theme.RESET}"
|
|
1643
|
+
)
|
|
1644
|
+
|
|
1645
|
+
# Build output
|
|
1646
|
+
output_lines = [
|
|
1647
|
+
self._draw_separator("light"),
|
|
1648
|
+
self._draw_box_line(output_label),
|
|
1649
|
+
]
|
|
1650
|
+
|
|
1651
|
+
for line in formatted_response.split("\n"):
|
|
1652
|
+
output_lines.append(self._draw_box_line(f" {line}"))
|
|
1653
|
+
|
|
1654
|
+
output_lines.append(self._draw_box_bottom(status_text))
|
|
1655
|
+
# No blank line after - let following content manage spacing
|
|
1656
|
+
|
|
1657
|
+
for line in output_lines:
|
|
1658
|
+
print(line)
|
|
1659
|
+
|
|
1660
|
+
def skill_call_compact(
|
|
1661
|
+
self,
|
|
1662
|
+
skill_name: str,
|
|
1663
|
+
params: Dict[str, Any],
|
|
1664
|
+
response: Any,
|
|
1665
|
+
success: bool = True,
|
|
1666
|
+
duration_ms: Optional[float] = None,
|
|
1667
|
+
verbose: Optional[bool] = None
|
|
1668
|
+
) -> None:
|
|
1669
|
+
"""
|
|
1670
|
+
Display a complete skill call in compact format (single box).
|
|
1671
|
+
|
|
1672
|
+
Args:
|
|
1673
|
+
skill_name: Name of the skill
|
|
1674
|
+
params: Input parameters
|
|
1675
|
+
response: Output response
|
|
1676
|
+
success: Whether successful
|
|
1677
|
+
duration_ms: Execution duration
|
|
1678
|
+
verbose: Override verbose setting
|
|
1679
|
+
"""
|
|
1680
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
1681
|
+
return
|
|
1682
|
+
|
|
1683
|
+
status = StatusType.SUCCESS if success else StatusType.ERROR
|
|
1684
|
+
status_icon = Theme.BOX_CHECK if success else Theme.BOX_CROSS
|
|
1685
|
+
status_color = Theme.SUCCESS if success else Theme.ERROR
|
|
1686
|
+
|
|
1687
|
+
# Format params compactly
|
|
1688
|
+
param_str = json.dumps(params, ensure_ascii=False, separators=(",", ":")) if params else "()"
|
|
1689
|
+
if len(param_str) > 60:
|
|
1690
|
+
param_str = param_str[:57] + "..."
|
|
1691
|
+
|
|
1692
|
+
# Format response compactly
|
|
1693
|
+
if isinstance(response, str):
|
|
1694
|
+
resp_str = self._truncate(response, 60)
|
|
1695
|
+
else:
|
|
1696
|
+
resp_str = json.dumps(response, ensure_ascii=False, separators=(",", ":"))[:60]
|
|
1697
|
+
|
|
1698
|
+
# Duration string
|
|
1699
|
+
duration_str = ""
|
|
1700
|
+
if duration_ms is not None:
|
|
1701
|
+
if duration_ms >= 1000:
|
|
1702
|
+
duration_str = f" {Theme.MUTED}({duration_ms/1000:.2f}s){Theme.RESET}"
|
|
1703
|
+
else:
|
|
1704
|
+
duration_str = f" {Theme.MUTED}({duration_ms:.0f}ms){Theme.RESET}"
|
|
1705
|
+
|
|
1706
|
+
# Single line format for very short calls
|
|
1707
|
+
print(
|
|
1708
|
+
f"\n{status_color}{status_icon}{Theme.RESET} "
|
|
1709
|
+
f"{Theme.BOLD}⚡ {Theme.TOOL_NAME}{skill_name}{Theme.RESET}"
|
|
1710
|
+
f"{Theme.MUTED}({Theme.RESET}{Theme.PARAM_VALUE}{param_str}{Theme.RESET}{Theme.MUTED}){Theme.RESET}"
|
|
1711
|
+
f" {Theme.MUTED}→{Theme.RESET} "
|
|
1712
|
+
f"{Theme.STRING_VALUE}{resp_str}{Theme.RESET}"
|
|
1713
|
+
f"{duration_str}\n"
|
|
1714
|
+
)
|
|
1715
|
+
|
|
1716
|
+
# ─────────────────────────────────────────────────────────────
|
|
1717
|
+
# Block Start/End Display
|
|
1718
|
+
# ─────────────────────────────────────────────────────────────
|
|
1719
|
+
|
|
1720
|
+
def block_start(
|
|
1721
|
+
self,
|
|
1722
|
+
block_type: str,
|
|
1723
|
+
output_var: str,
|
|
1724
|
+
content_preview: Optional[str] = None,
|
|
1725
|
+
verbose: Optional[bool] = None
|
|
1726
|
+
) -> None:
|
|
1727
|
+
"""
|
|
1728
|
+
Display the start of a code block execution.
|
|
1729
|
+
|
|
1730
|
+
Args:
|
|
1731
|
+
block_type: Type of block (explore, prompt, judge, etc.)
|
|
1732
|
+
output_var: Variable to store output
|
|
1733
|
+
content_preview: Preview of block content
|
|
1734
|
+
verbose: Override verbose setting
|
|
1735
|
+
"""
|
|
1736
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
1737
|
+
return
|
|
1738
|
+
|
|
1739
|
+
# Block type icons and colors
|
|
1740
|
+
block_icons = {
|
|
1741
|
+
"explore": ("🔍", Theme.SECONDARY),
|
|
1742
|
+
"prompt": ("💬", Theme.SUCCESS),
|
|
1743
|
+
"judge": ("⚖️", Theme.WARNING),
|
|
1744
|
+
"assign": ("📝", Theme.PRIMARY),
|
|
1745
|
+
"tool": ("⚡", Theme.ACCENT),
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
icon, color = block_icons.get(block_type.lower(), ("📦", Theme.LABEL))
|
|
1749
|
+
|
|
1750
|
+
# Build output line
|
|
1751
|
+
header = f"{color}{Theme.BOLD}{icon} {block_type.upper()}{Theme.RESET}"
|
|
1752
|
+
var_display = f"{Theme.PRIMARY}{output_var}{Theme.RESET}"
|
|
1753
|
+
arrow = f"{Theme.MUTED}→{Theme.RESET}"
|
|
1754
|
+
|
|
1755
|
+
line = f"{header} {var_display} {arrow}"
|
|
1756
|
+
|
|
1757
|
+
if content_preview:
|
|
1758
|
+
preview = self._truncate(content_preview.strip(), 50)
|
|
1759
|
+
line += f" {Theme.MUTED}{preview}{Theme.RESET}"
|
|
1760
|
+
|
|
1761
|
+
# Pause status bar to avoid output conflicts
|
|
1762
|
+
with pause_status_bar_context():
|
|
1763
|
+
print(line)
|
|
1764
|
+
|
|
1765
|
+
def thinking_indicator(
|
|
1766
|
+
self,
|
|
1767
|
+
message: str = "Thinking",
|
|
1768
|
+
verbose: Optional[bool] = None
|
|
1769
|
+
) -> None:
|
|
1770
|
+
"""
|
|
1771
|
+
Display a thinking/processing indicator.
|
|
1772
|
+
|
|
1773
|
+
Args:
|
|
1774
|
+
message: Message to display
|
|
1775
|
+
verbose: Override verbose setting
|
|
1776
|
+
"""
|
|
1777
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
1778
|
+
return
|
|
1779
|
+
|
|
1780
|
+
print(f"{Theme.MUTED}{Theme.BOX_SPINNER_FRAMES[0]} {message}...{Theme.RESET}", end="\r")
|
|
1781
|
+
|
|
1782
|
+
def agent_skill_enter(
|
|
1783
|
+
self,
|
|
1784
|
+
skill_name: str,
|
|
1785
|
+
message: Optional[str] = None,
|
|
1786
|
+
verbose: Optional[bool] = None,
|
|
1787
|
+
) -> None:
|
|
1788
|
+
"""
|
|
1789
|
+
Print a visual delimiter indicating a sub-agent (agent-as-skill) has started.
|
|
1790
|
+
"""
|
|
1791
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
1792
|
+
return
|
|
1793
|
+
|
|
1794
|
+
label = message or f"🚀 AGENT ACTIVATE: {skill_name}"
|
|
1795
|
+
self._draw_banner(label, style="success")
|
|
1796
|
+
|
|
1797
|
+
def agent_skill_exit(
|
|
1798
|
+
self,
|
|
1799
|
+
skill_name: str,
|
|
1800
|
+
message: Optional[str] = None,
|
|
1801
|
+
verbose: Optional[bool] = None,
|
|
1802
|
+
) -> None:
|
|
1803
|
+
"""
|
|
1804
|
+
Print a visual delimiter indicating a sub-agent (agent-as-skill) has exited.
|
|
1805
|
+
|
|
1806
|
+
This is intentionally UI-only: core execution should not embed terminal formatting logic.
|
|
1807
|
+
"""
|
|
1808
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
1809
|
+
return
|
|
1810
|
+
|
|
1811
|
+
label = message or f"🏁 AGENT COMPLETE: {skill_name}"
|
|
1812
|
+
self._draw_banner(label, style="secondary")
|
|
1813
|
+
|
|
1814
|
+
def _draw_banner(self, text: str, style: str = "primary") -> None:
|
|
1815
|
+
"""Draw a bold banner for major events"""
|
|
1816
|
+
width = min(self.style.width, self._get_terminal_width() - 4)
|
|
1817
|
+
|
|
1818
|
+
if style == "success":
|
|
1819
|
+
color = Theme.SUCCESS
|
|
1820
|
+
elif style == "secondary":
|
|
1821
|
+
color = Theme.SECONDARY
|
|
1822
|
+
elif style == "error":
|
|
1823
|
+
color = Theme.ERROR
|
|
1824
|
+
else:
|
|
1825
|
+
color = Theme.PRIMARY
|
|
1826
|
+
|
|
1827
|
+
# Top border
|
|
1828
|
+
print(f"\n{color}{Theme.BOX_TOP_LEFT}{Theme.BOX_HORIZONTAL * width}{Theme.BOX_TOP_RIGHT}{Theme.RESET}")
|
|
1829
|
+
|
|
1830
|
+
# Content string
|
|
1831
|
+
# Ensure text is not too long
|
|
1832
|
+
if len(text) > width - 4:
|
|
1833
|
+
text = text[:width - 7] + "..."
|
|
1834
|
+
|
|
1835
|
+
padding_left = (width - len(text)) // 2
|
|
1836
|
+
padding_right = width - len(text) - padding_left
|
|
1837
|
+
|
|
1838
|
+
print(f"{color}{Theme.BOX_VERTICAL}{Theme.RESET}{' ' * padding_left}{Theme.BOLD}{text}{Theme.RESET}{' ' * padding_right}{color}{Theme.BOX_VERTICAL}{Theme.RESET}")
|
|
1839
|
+
|
|
1840
|
+
# Bottom border
|
|
1841
|
+
print(f"{color}{Theme.BOX_BOTTOM_LEFT}{Theme.BOX_HORIZONTAL * width}{Theme.BOX_BOTTOM_RIGHT}{Theme.RESET}\n")
|
|
1842
|
+
|
|
1843
|
+
# ─────────────────────────────────────────────────────────────
|
|
1844
|
+
# Session/Conversation Display
|
|
1845
|
+
# ─────────────────────────────────────────────────────────────
|
|
1846
|
+
|
|
1847
|
+
def session_start(
|
|
1848
|
+
self,
|
|
1849
|
+
session_type: str,
|
|
1850
|
+
target: str,
|
|
1851
|
+
verbose: Optional[bool] = None
|
|
1852
|
+
) -> None:
|
|
1853
|
+
"""Display session start with Blackbox-style gradient banner with shadows.
|
|
1854
|
+
|
|
1855
|
+
Features:
|
|
1856
|
+
- Large pixel art "DOLPHIN"
|
|
1857
|
+
- Gradient colors (yellow -> green -> pink)
|
|
1858
|
+
- Shadow effect using ▓ characters
|
|
1859
|
+
"""
|
|
1860
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
1861
|
+
return
|
|
1862
|
+
|
|
1863
|
+
# Build combined banner lines with gradient colors and shadow
|
|
1864
|
+
letters = Theme.BANNER_LETTERS
|
|
1865
|
+
word = Theme.BANNER_WORD
|
|
1866
|
+
main_color = Theme.BANNER_COLOR
|
|
1867
|
+
hollow_color = Theme.BANNER_HOLLOW_COLOR
|
|
1868
|
+
num_rows = 6 # Each letter has 6 rows
|
|
1869
|
+
|
|
1870
|
+
banner_lines = []
|
|
1871
|
+
for row in range(num_rows):
|
|
1872
|
+
line_parts = []
|
|
1873
|
+
for char in word:
|
|
1874
|
+
letter_art = letters.get(char, [' '] * 6)
|
|
1875
|
+
|
|
1876
|
+
# Process each character: █ gets main color, ░ gets hollow color
|
|
1877
|
+
styled_segment = ""
|
|
1878
|
+
for c in letter_art[row]:
|
|
1879
|
+
if c == '█':
|
|
1880
|
+
styled_segment += f"{main_color}{c}{Theme.RESET}"
|
|
1881
|
+
elif c == '░':
|
|
1882
|
+
styled_segment += f"{hollow_color}{c}{Theme.RESET}"
|
|
1883
|
+
else:
|
|
1884
|
+
styled_segment += c
|
|
1885
|
+
line_parts.append(styled_segment)
|
|
1886
|
+
banner_lines.append(''.join(line_parts))
|
|
1887
|
+
|
|
1888
|
+
# Calculate width - each letter is ~9 chars wide, 7 letters
|
|
1889
|
+
content_width = len(word) * 9
|
|
1890
|
+
padding = 2
|
|
1891
|
+
border_width = content_width + padding * 2
|
|
1892
|
+
|
|
1893
|
+
# Use rounded box characters for clean border
|
|
1894
|
+
border_color = Theme.BORDER_ACCENT
|
|
1895
|
+
top_border = f"{border_color}{Theme.BOLD}{Theme.BOX_TOP_LEFT}{Theme.BOX_HORIZONTAL * border_width}{Theme.BOX_TOP_RIGHT}{Theme.RESET}"
|
|
1896
|
+
bottom_border = f"{border_color}{Theme.BOLD}{Theme.BOX_BOTTOM_LEFT}{Theme.BOX_HORIZONTAL * border_width}{Theme.BOX_BOTTOM_RIGHT}{Theme.RESET}"
|
|
1897
|
+
|
|
1898
|
+
# Clear line before each print to remove any residual text from Rich status spinner
|
|
1899
|
+
# This ensures the LOGO displays cleanly without trailing artifacts
|
|
1900
|
+
import sys
|
|
1901
|
+
|
|
1902
|
+
print()
|
|
1903
|
+
sys.stdout.write("\033[K") # Clear to end of line
|
|
1904
|
+
print(top_border)
|
|
1905
|
+
|
|
1906
|
+
# Empty padding row
|
|
1907
|
+
empty_content = ' ' * content_width
|
|
1908
|
+
sys.stdout.write("\033[K")
|
|
1909
|
+
print(f"{border_color}{Theme.BOLD}{Theme.BOX_VERTICAL}{Theme.RESET}{' ' * padding}{empty_content}{' ' * padding}{border_color}{Theme.BOLD}{Theme.BOX_VERTICAL}{Theme.RESET}")
|
|
1910
|
+
|
|
1911
|
+
# Print banner lines
|
|
1912
|
+
for line in banner_lines:
|
|
1913
|
+
# Line already has colors, just add padding and borders
|
|
1914
|
+
sys.stdout.write("\033[K")
|
|
1915
|
+
print(f"{border_color}{Theme.BOLD}{Theme.BOX_VERTICAL}{Theme.RESET}{' ' * padding}{line}{' ' * padding}{border_color}{Theme.BOLD}{Theme.BOX_VERTICAL}{Theme.RESET}")
|
|
1916
|
+
|
|
1917
|
+
# Empty padding row
|
|
1918
|
+
sys.stdout.write("\033[K")
|
|
1919
|
+
print(f"{border_color}{Theme.BOLD}{Theme.BOX_VERTICAL}{Theme.RESET}{' ' * padding}{empty_content}{' ' * padding}{border_color}{Theme.BOLD}{Theme.BOX_VERTICAL}{Theme.RESET}")
|
|
1920
|
+
|
|
1921
|
+
sys.stdout.write("\033[K")
|
|
1922
|
+
print(bottom_border)
|
|
1923
|
+
|
|
1924
|
+
# Session info
|
|
1925
|
+
print()
|
|
1926
|
+
print(f"{Theme.SUCCESS}{Theme.BOLD}🚀 Starting {session_type} session{Theme.RESET}")
|
|
1927
|
+
print(f"{Theme.PRIMARY} Target: {target}{Theme.RESET}")
|
|
1928
|
+
print(f"{Theme.MUTED} Type 'exit', 'quit', or 'q' to end{Theme.RESET}")
|
|
1929
|
+
print()
|
|
1930
|
+
|
|
1931
|
+
def session_end(self, verbose: Optional[bool] = None) -> None:
|
|
1932
|
+
"""Display session end"""
|
|
1933
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
1934
|
+
return
|
|
1935
|
+
|
|
1936
|
+
width = min(40, self._get_terminal_width() - 4)
|
|
1937
|
+
border = f"{Theme.BORDER}{'═' * width}{Theme.RESET}"
|
|
1938
|
+
|
|
1939
|
+
print(f"\n{border}")
|
|
1940
|
+
print(f"{Theme.WARNING}{Theme.BOLD}👋 Session ended{Theme.RESET}")
|
|
1941
|
+
print(f"{border}\n")
|
|
1942
|
+
|
|
1943
|
+
def display_session_info(
|
|
1944
|
+
self,
|
|
1945
|
+
skillkit_info: dict = None,
|
|
1946
|
+
show_commands: bool = True,
|
|
1947
|
+
verbose: Optional[bool] = None
|
|
1948
|
+
) -> None:
|
|
1949
|
+
"""Display available skillkits and command hints after session start.
|
|
1950
|
+
|
|
1951
|
+
Args:
|
|
1952
|
+
skillkit_info: Dict mapping skillkit name to tool count
|
|
1953
|
+
show_commands: Whether to show available slash commands
|
|
1954
|
+
verbose: Override verbose setting
|
|
1955
|
+
"""
|
|
1956
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
1957
|
+
return
|
|
1958
|
+
|
|
1959
|
+
if skillkit_info:
|
|
1960
|
+
print(f"{Theme.SECONDARY}📦 Available Skillkits:{Theme.RESET}")
|
|
1961
|
+
for name, count in sorted(skillkit_info.items()):
|
|
1962
|
+
print(f"{Theme.MUTED} • {name} ({count} tools){Theme.RESET}")
|
|
1963
|
+
|
|
1964
|
+
if show_commands:
|
|
1965
|
+
print(f"{Theme.MUTED}💡 Commands: /help • exit/quit/q to end{Theme.RESET}")
|
|
1966
|
+
|
|
1967
|
+
print()
|
|
1968
|
+
|
|
1969
|
+
def user_input_display(
|
|
1970
|
+
self,
|
|
1971
|
+
user_input: str,
|
|
1972
|
+
verbose: Optional[bool] = None
|
|
1973
|
+
) -> None:
|
|
1974
|
+
"""Display formatted user input in a clean, minimal style"""
|
|
1975
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
1976
|
+
return
|
|
1977
|
+
|
|
1978
|
+
if user_input.strip():
|
|
1979
|
+
content = user_input.strip()
|
|
1980
|
+
else:
|
|
1981
|
+
content = f"{Theme.MUTED}(empty input){Theme.RESET}"
|
|
1982
|
+
|
|
1983
|
+
# Clean, minimal style like Claude Code - single newline before for separation
|
|
1984
|
+
print(f"{Theme.SECONDARY}{Theme.BOLD}>{Theme.RESET} {content}")
|
|
1985
|
+
|
|
1986
|
+
def agent_label(
|
|
1987
|
+
self,
|
|
1988
|
+
agent_name: str,
|
|
1989
|
+
verbose: Optional[bool] = None
|
|
1990
|
+
) -> None:
|
|
1991
|
+
"""Display agent response label"""
|
|
1992
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
1993
|
+
return
|
|
1994
|
+
|
|
1995
|
+
print(f"\n{Theme.SUCCESS}{Theme.BOLD}🤖 {agent_name}{Theme.RESET}")
|
|
1996
|
+
|
|
1997
|
+
# ─────────────────────────────────────────────────────────────
|
|
1998
|
+
# Plan/Task Progress Display
|
|
1999
|
+
# ─────────────────────────────────────────────────────────────
|
|
2000
|
+
|
|
2001
|
+
def task_progress(
|
|
2002
|
+
self,
|
|
2003
|
+
current: int,
|
|
2004
|
+
total: int,
|
|
2005
|
+
current_task: str,
|
|
2006
|
+
verbose: Optional[bool] = None
|
|
2007
|
+
) -> None:
|
|
2008
|
+
"""
|
|
2009
|
+
Display task progress with a visual progress bar.
|
|
2010
|
+
|
|
2011
|
+
Args:
|
|
2012
|
+
current: Current task number (1-indexed)
|
|
2013
|
+
total: Total number of tasks
|
|
2014
|
+
current_task: Description of current task
|
|
2015
|
+
verbose: Override verbose setting
|
|
2016
|
+
"""
|
|
2017
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
2018
|
+
return
|
|
2019
|
+
|
|
2020
|
+
# Calculate progress
|
|
2021
|
+
percentage = (current / total * 100) if total > 0 else 0
|
|
2022
|
+
bar_width = 20
|
|
2023
|
+
filled = int(bar_width * current / total) if total > 0 else 0
|
|
2024
|
+
bar = "█" * filled + "░" * (bar_width - filled)
|
|
2025
|
+
|
|
2026
|
+
# Color based on progress
|
|
2027
|
+
if percentage >= 100:
|
|
2028
|
+
color = Theme.SUCCESS
|
|
2029
|
+
elif percentage >= 50:
|
|
2030
|
+
color = Theme.PRIMARY
|
|
2031
|
+
else:
|
|
2032
|
+
color = Theme.ACCENT
|
|
2033
|
+
|
|
2034
|
+
# Build progress line
|
|
2035
|
+
progress_line = (
|
|
2036
|
+
f"{color}[{bar}]{Theme.RESET} "
|
|
2037
|
+
f"{Theme.BOLD}{current}/{total}{Theme.RESET} "
|
|
2038
|
+
f"{Theme.MUTED}({percentage:.0f}%){Theme.RESET}"
|
|
2039
|
+
)
|
|
2040
|
+
|
|
2041
|
+
print(f"\n{progress_line}")
|
|
2042
|
+
if current_task:
|
|
2043
|
+
print(f" {Theme.LABEL}▸{Theme.RESET} {current_task}")
|
|
2044
|
+
|
|
2045
|
+
# ─────────────────────────────────────────────────────────────
|
|
2046
|
+
# Codex-Style Components (New)
|
|
2047
|
+
# ─────────────────────────────────────────────────────────────
|
|
2048
|
+
|
|
2049
|
+
def task_list_tree(
|
|
2050
|
+
self,
|
|
2051
|
+
tasks: List[Dict[str, Any]],
|
|
2052
|
+
title: str = "Updated Plan",
|
|
2053
|
+
style: str = "codex",
|
|
2054
|
+
verbose: Optional[bool] = None
|
|
2055
|
+
) -> None:
|
|
2056
|
+
"""
|
|
2057
|
+
Render a Codex-style tree task list with checkboxes.
|
|
2058
|
+
|
|
2059
|
+
Args:
|
|
2060
|
+
tasks: List of tasks with 'content'/'name', 'status', optional 'children'
|
|
2061
|
+
title: Title for the task list
|
|
2062
|
+
style: 'codex' for checkbox style, 'emoji' for emoji style
|
|
2063
|
+
verbose: Override verbose setting
|
|
2064
|
+
|
|
2065
|
+
Example output (codex style):
|
|
2066
|
+
• Updated Plan
|
|
2067
|
+
└─ □ 扫描仓库结构与说明文档
|
|
2068
|
+
└─ ☑ 梳理核心模块与依赖关系
|
|
2069
|
+
└─ □ 总结构建运行与开发流程
|
|
2070
|
+
"""
|
|
2071
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
2072
|
+
return
|
|
2073
|
+
|
|
2074
|
+
# Status icons based on style
|
|
2075
|
+
if style == "codex":
|
|
2076
|
+
status_icons = {
|
|
2077
|
+
"pending": ("□", Theme.MUTED),
|
|
2078
|
+
"in_progress": ("◐", Theme.PRIMARY),
|
|
2079
|
+
"running": ("◐", Theme.PRIMARY),
|
|
2080
|
+
"completed": ("☑", Theme.SUCCESS),
|
|
2081
|
+
"done": ("☑", Theme.SUCCESS),
|
|
2082
|
+
"success": ("☑", Theme.SUCCESS),
|
|
2083
|
+
"paused": ("▫", Theme.WARNING),
|
|
2084
|
+
"cancelled": ("☒", Theme.ERROR),
|
|
2085
|
+
"error": ("☒", Theme.ERROR),
|
|
2086
|
+
"skipped": ("○", Theme.MUTED),
|
|
2087
|
+
}
|
|
2088
|
+
else: # emoji style
|
|
2089
|
+
status_icons = {
|
|
2090
|
+
"pending": ("⏳", Theme.MUTED),
|
|
2091
|
+
"in_progress": ("🔄", Theme.PRIMARY),
|
|
2092
|
+
"running": ("🔄", Theme.PRIMARY),
|
|
2093
|
+
"completed": ("✅", Theme.SUCCESS),
|
|
2094
|
+
"done": ("✅", Theme.SUCCESS),
|
|
2095
|
+
"success": ("✅", Theme.SUCCESS),
|
|
2096
|
+
"paused": ("⏸️", Theme.WARNING),
|
|
2097
|
+
"cancelled": ("❌", Theme.ERROR),
|
|
2098
|
+
"error": ("❌", Theme.ERROR),
|
|
2099
|
+
"skipped": ("○", Theme.MUTED),
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
# Calculate progress
|
|
2103
|
+
total = len(tasks)
|
|
2104
|
+
completed = sum(1 for t in tasks if t.get("status") in ("completed", "done", "success"))
|
|
2105
|
+
percentage = (completed / total * 100) if total > 0 else 0
|
|
2106
|
+
|
|
2107
|
+
# Header with bullet point
|
|
2108
|
+
progress_str = f"{Theme.MUTED}({completed}/{total} · {percentage:.0f}%){Theme.RESET}"
|
|
2109
|
+
print(f"\n{Theme.PRIMARY}•{Theme.RESET} {Theme.BOLD}{title}{Theme.RESET} {progress_str}")
|
|
2110
|
+
|
|
2111
|
+
# Render tasks
|
|
2112
|
+
for i, task in enumerate(tasks):
|
|
2113
|
+
content = task.get("content", task.get("name", task.get("description", f"Task {i+1}")))
|
|
2114
|
+
status = task.get("status", "pending").lower()
|
|
2115
|
+
icon, color = status_icons.get(status, ("○", Theme.MUTED))
|
|
2116
|
+
|
|
2117
|
+
# Tree connector (└─ for all items)
|
|
2118
|
+
prefix = f" {Theme.MUTED}└─{Theme.RESET}"
|
|
2119
|
+
|
|
2120
|
+
# Highlight running/in_progress tasks
|
|
2121
|
+
if status in ("running", "in_progress"):
|
|
2122
|
+
task_line = f"{prefix} {color}{icon}{Theme.RESET} {Theme.BOLD}{content}{Theme.RESET}"
|
|
2123
|
+
else:
|
|
2124
|
+
task_line = f"{prefix} {color}{icon}{Theme.RESET} {content}"
|
|
2125
|
+
|
|
2126
|
+
print(task_line)
|
|
2127
|
+
|
|
2128
|
+
# Render children if present
|
|
2129
|
+
children = task.get("children", [])
|
|
2130
|
+
for child in children:
|
|
2131
|
+
child_content = child.get("content", child.get("name", ""))
|
|
2132
|
+
child_status = child.get("status", "pending").lower()
|
|
2133
|
+
child_icon, child_color = status_icons.get(child_status, ("○", Theme.MUTED))
|
|
2134
|
+
child_line = f" {Theme.MUTED}·{Theme.RESET} {child_color}{child_icon}{Theme.RESET} {child_content}"
|
|
2135
|
+
print(child_line)
|
|
2136
|
+
|
|
2137
|
+
print() # Trailing newline
|
|
2138
|
+
|
|
2139
|
+
def collapsible_text(
|
|
2140
|
+
self,
|
|
2141
|
+
text: str,
|
|
2142
|
+
max_lines: int = 5,
|
|
2143
|
+
verbose: Optional[bool] = None
|
|
2144
|
+
) -> str:
|
|
2145
|
+
"""
|
|
2146
|
+
Display long text with automatic folding.
|
|
2147
|
+
|
|
2148
|
+
Args:
|
|
2149
|
+
text: The text to display
|
|
2150
|
+
max_lines: Maximum lines to show before folding
|
|
2151
|
+
verbose: Override verbose setting
|
|
2152
|
+
|
|
2153
|
+
Returns:
|
|
2154
|
+
Formatted text with fold indicator if applicable
|
|
2155
|
+
|
|
2156
|
+
Example:
|
|
2157
|
+
Line 1
|
|
2158
|
+
Line 2
|
|
2159
|
+
... +72 lines
|
|
2160
|
+
"""
|
|
2161
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
2162
|
+
return ""
|
|
2163
|
+
|
|
2164
|
+
lines = text.split('\n')
|
|
2165
|
+
if len(lines) <= max_lines:
|
|
2166
|
+
return text
|
|
2167
|
+
|
|
2168
|
+
visible_lines = lines[:max_lines]
|
|
2169
|
+
hidden_count = len(lines) - max_lines
|
|
2170
|
+
|
|
2171
|
+
result = '\n'.join(visible_lines)
|
|
2172
|
+
result += f"\n{self._format_hidden_lines_hint(hidden_count)}"
|
|
2173
|
+
|
|
2174
|
+
return result
|
|
2175
|
+
|
|
2176
|
+
def print_collapsible(
|
|
2177
|
+
self,
|
|
2178
|
+
text: str,
|
|
2179
|
+
max_lines: int = 5,
|
|
2180
|
+
verbose: Optional[bool] = None
|
|
2181
|
+
) -> None:
|
|
2182
|
+
"""Print text with automatic folding."""
|
|
2183
|
+
formatted = self.collapsible_text(text, max_lines, verbose)
|
|
2184
|
+
if formatted:
|
|
2185
|
+
print(formatted)
|
|
2186
|
+
|
|
2187
|
+
def timed_status(
|
|
2188
|
+
self,
|
|
2189
|
+
message: str,
|
|
2190
|
+
elapsed_seconds: int = 0,
|
|
2191
|
+
show_interrupt_hint: bool = True,
|
|
2192
|
+
verbose: Optional[bool] = None
|
|
2193
|
+
) -> None:
|
|
2194
|
+
"""
|
|
2195
|
+
Display a status line with elapsed time (like Codex CLI).
|
|
2196
|
+
|
|
2197
|
+
Args:
|
|
2198
|
+
message: The status message
|
|
2199
|
+
elapsed_seconds: Elapsed time in seconds
|
|
2200
|
+
show_interrupt_hint: Whether to show 'esc to interrupt'
|
|
2201
|
+
verbose: Override verbose setting
|
|
2202
|
+
|
|
2203
|
+
Example output:
|
|
2204
|
+
• Planning repository inspection (27s • esc to interrupt)
|
|
2205
|
+
"""
|
|
2206
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
2207
|
+
return
|
|
2208
|
+
|
|
2209
|
+
time_str = f"{elapsed_seconds}s"
|
|
2210
|
+
hint_str = " • esc to interrupt" if show_interrupt_hint else ""
|
|
2211
|
+
|
|
2212
|
+
print(f"\n{Theme.PRIMARY}•{Theme.RESET} {message} {Theme.MUTED}({time_str}{hint_str}){Theme.RESET}")
|
|
2213
|
+
|
|
2214
|
+
def action_item(
|
|
2215
|
+
self,
|
|
2216
|
+
action: str,
|
|
2217
|
+
description: str,
|
|
2218
|
+
details: Optional[List[str]] = None,
|
|
2219
|
+
verbose: Optional[bool] = None
|
|
2220
|
+
) -> None:
|
|
2221
|
+
"""
|
|
2222
|
+
Display an action item with optional details (Codex-style).
|
|
2223
|
+
|
|
2224
|
+
Args:
|
|
2225
|
+
action: The action name (e.g., "Explored", "Ran", "Updated Plan")
|
|
2226
|
+
description: Brief description
|
|
2227
|
+
details: Optional list of detail lines
|
|
2228
|
+
verbose: Override verbose setting
|
|
2229
|
+
|
|
2230
|
+
Example:
|
|
2231
|
+
• Explored
|
|
2232
|
+
└─ List ls -la
|
|
2233
|
+
Search AGENTS.md in ..
|
|
2234
|
+
"""
|
|
2235
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
2236
|
+
return
|
|
2237
|
+
|
|
2238
|
+
# Action header
|
|
2239
|
+
print(f"\n{Theme.PRIMARY}•{Theme.RESET} {Theme.BOLD}{action}{Theme.RESET}")
|
|
2240
|
+
|
|
2241
|
+
# Description with tree connector
|
|
2242
|
+
if description:
|
|
2243
|
+
print(f" {Theme.MUTED}└─{Theme.RESET} {description}")
|
|
2244
|
+
|
|
2245
|
+
# Details as sub-items
|
|
2246
|
+
if details:
|
|
2247
|
+
for i, detail in enumerate(details[:10]): # Limit to 10 items
|
|
2248
|
+
print(f" {Theme.MUTED}{detail}{Theme.RESET}")
|
|
2249
|
+
if len(details) > 10:
|
|
2250
|
+
print(f" {Theme.MUTED}… +{len(details) - 10} more{Theme.RESET}")
|
|
2251
|
+
|
|
2252
|
+
# ─────────────────────────────────────────────────────────────
|
|
2253
|
+
# Plan-Specific Renderer (Unique Style for plan_act)
|
|
2254
|
+
# ─────────────────────────────────────────────────────────────
|
|
2255
|
+
|
|
2256
|
+
def _get_visual_width(self, text: str) -> int:
|
|
2257
|
+
"""Calculate the visual width of a string in terminal (handling double-width CJK)."""
|
|
2258
|
+
# Remove ANSI escape codes before calculating width
|
|
2259
|
+
clean_text = ""
|
|
2260
|
+
skip = False
|
|
2261
|
+
for i, char in enumerate(text):
|
|
2262
|
+
if char == "\033":
|
|
2263
|
+
skip = True
|
|
2264
|
+
if not skip:
|
|
2265
|
+
clean_text += char
|
|
2266
|
+
if skip and char == "m":
|
|
2267
|
+
skip = False
|
|
2268
|
+
|
|
2269
|
+
width = 0
|
|
2270
|
+
for char in clean_text:
|
|
2271
|
+
if unicodedata.east_asian_width(char) in ("W", "F", "A"):
|
|
2272
|
+
width += 2
|
|
2273
|
+
else:
|
|
2274
|
+
width += 1
|
|
2275
|
+
return width
|
|
2276
|
+
|
|
2277
|
+
# ─────────────────────────────────────────────────────────────
|
|
2278
|
+
# Plan-Specific Renderer (Unique Style for plan_act)
|
|
2279
|
+
# ─────────────────────────────────────────────────────────────
|
|
2280
|
+
|
|
2281
|
+
def plan_update(
|
|
2282
|
+
self,
|
|
2283
|
+
tasks: List[Dict[str, Any]],
|
|
2284
|
+
current_action: Optional[str] = None,
|
|
2285
|
+
current_task_id: Optional[int] = None,
|
|
2286
|
+
current_task_content: Optional[str] = None,
|
|
2287
|
+
conclusions: Optional[str] = None,
|
|
2288
|
+
elapsed_seconds: Optional[int] = None,
|
|
2289
|
+
verbose: Optional[bool] = None
|
|
2290
|
+
) -> None:
|
|
2291
|
+
"""
|
|
2292
|
+
Render a complete plan update in a unique style for plan_act.
|
|
2293
|
+
|
|
2294
|
+
This is a specialized renderer that replaces the generic skill_call box
|
|
2295
|
+
with a distinctive plan-focused visual design.
|
|
2296
|
+
|
|
2297
|
+
Args:
|
|
2298
|
+
tasks: List of tasks with 'content', 'status'
|
|
2299
|
+
current_action: Current action type ('create', 'start', 'done', 'pause', 'skip')
|
|
2300
|
+
current_task_id: ID of the task being operated on
|
|
2301
|
+
current_task_content: Content of the current task
|
|
2302
|
+
conclusions: Conclusions for completed tasks
|
|
2303
|
+
elapsed_seconds: Optional elapsed time for status display
|
|
2304
|
+
verbose: Override verbose setting
|
|
2305
|
+
"""
|
|
2306
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
2307
|
+
return
|
|
2308
|
+
|
|
2309
|
+
if not tasks:
|
|
2310
|
+
return
|
|
2311
|
+
|
|
2312
|
+
# No need to pause StatusBar in fixed-position mode - it uses cursor save/restore
|
|
2313
|
+
self._render_plan_update_internal(
|
|
2314
|
+
tasks, current_action, current_task_id,
|
|
2315
|
+
current_task_content, conclusions, elapsed_seconds
|
|
2316
|
+
)
|
|
2317
|
+
|
|
2318
|
+
def _render_plan_update_internal(
|
|
2319
|
+
self,
|
|
2320
|
+
tasks: List[Dict[str, Any]],
|
|
2321
|
+
current_action: Optional[str] = None,
|
|
2322
|
+
current_task_id: Optional[int] = None,
|
|
2323
|
+
current_task_content: Optional[str] = None,
|
|
2324
|
+
conclusions: Optional[str] = None,
|
|
2325
|
+
elapsed_seconds: Optional[int] = None
|
|
2326
|
+
) -> None:
|
|
2327
|
+
PLAN_PRIMARY = "\033[38;5;44m" # Modern Teal/Cyan
|
|
2328
|
+
PLAN_ACCENT = "\033[38;5;80m" # Lighter Teal
|
|
2329
|
+
PLAN_MUTED = "\033[38;5;242m" # Dimmed gray
|
|
2330
|
+
|
|
2331
|
+
# Status icons (plan-specific style)
|
|
2332
|
+
# Use simple spinner frames for "in_progress" to give a dynamic feel
|
|
2333
|
+
spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
2334
|
+
current_frame = spinner_frames[int(time.time() * 10) % len(spinner_frames)]
|
|
2335
|
+
|
|
2336
|
+
status_icons = {
|
|
2337
|
+
"pending": ("○", Theme.MUTED),
|
|
2338
|
+
"in_progress": (current_frame, PLAN_PRIMARY),
|
|
2339
|
+
"running": (current_frame, PLAN_PRIMARY),
|
|
2340
|
+
"completed": ("●", Theme.SUCCESS),
|
|
2341
|
+
"done": ("●", Theme.SUCCESS),
|
|
2342
|
+
"success": ("●", Theme.SUCCESS),
|
|
2343
|
+
"paused": ("◎", Theme.WARNING),
|
|
2344
|
+
"cancelled": ("⊘", Theme.ERROR),
|
|
2345
|
+
"error": ("⊘", Theme.ERROR),
|
|
2346
|
+
"skipped": ("○", Theme.MUTED),
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
# Calculate stats
|
|
2350
|
+
total = len(tasks)
|
|
2351
|
+
completed = sum(1 for t in tasks if t.get("status") in ("completed", "done", "success"))
|
|
2352
|
+
|
|
2353
|
+
# Build the plan card
|
|
2354
|
+
term_width = self._get_terminal_width()
|
|
2355
|
+
width = min(self.style.width, term_width - 4)
|
|
2356
|
+
if width < 40: width = 40 # Minimum usable width
|
|
2357
|
+
|
|
2358
|
+
# Header
|
|
2359
|
+
title = "Plan Update"
|
|
2360
|
+
progress = f"{completed}/{total}"
|
|
2361
|
+
header_text = f" 📋 {title}"
|
|
2362
|
+
|
|
2363
|
+
# Calculate padding using visual width
|
|
2364
|
+
v_header_w = self._get_visual_width(header_text)
|
|
2365
|
+
v_progress_w = self._get_visual_width(progress)
|
|
2366
|
+
header_padding = width - v_header_w - v_progress_w - 4
|
|
2367
|
+
|
|
2368
|
+
# No leading blank - previous content should manage spacing
|
|
2369
|
+
print(f"{PLAN_PRIMARY}{Theme.BOX_TOP_LEFT}{Theme.BOX_HORIZONTAL * (width)}{Theme.BOX_TOP_RIGHT}{Theme.RESET}")
|
|
2370
|
+
print(f"{PLAN_PRIMARY}{Theme.BOX_VERTICAL}{Theme.RESET}{Theme.BOLD}{header_text}{Theme.RESET}{' ' * max(0, header_padding)}{PLAN_ACCENT}{progress}{Theme.RESET} {PLAN_PRIMARY}{Theme.BOX_VERTICAL}{Theme.RESET}")
|
|
2371
|
+
print(f"{PLAN_PRIMARY}{Theme.BOX_VERTICAL}{Theme.BOX_HORIZONTAL * (width)}{Theme.BOX_VERTICAL}{Theme.RESET}")
|
|
2372
|
+
|
|
2373
|
+
# Task list
|
|
2374
|
+
for i, task in enumerate(tasks):
|
|
2375
|
+
content = task.get("content", task.get("name", f"Task {i+1}"))
|
|
2376
|
+
status = task.get("status", "pending").lower()
|
|
2377
|
+
|
|
2378
|
+
# Truncate long content based on visual width
|
|
2379
|
+
max_v_content = width - 10
|
|
2380
|
+
v_content = self._get_visual_width(content)
|
|
2381
|
+
if v_content > max_v_content:
|
|
2382
|
+
# Simple truncation that's safe for multi-byte
|
|
2383
|
+
content = content[:int(max_v_content/1.5)] + "..."
|
|
2384
|
+
v_content = self._get_visual_width(content)
|
|
2385
|
+
|
|
2386
|
+
icon, color = status_icons.get(status, ("○", Theme.MUTED))
|
|
2387
|
+
|
|
2388
|
+
# Use dynamic icon if it's the current task being worked on
|
|
2389
|
+
is_current = (current_task_id is not None and i + 1 == current_task_id)
|
|
2390
|
+
if is_current and status in ("pending", "running", "in_progress"):
|
|
2391
|
+
icon, color = current_frame, PLAN_PRIMARY
|
|
2392
|
+
|
|
2393
|
+
if is_current:
|
|
2394
|
+
task_line = f" {color}{icon}{Theme.RESET} {Theme.BOLD}{content}{Theme.RESET}"
|
|
2395
|
+
indicator = f" {PLAN_PRIMARY}←{Theme.RESET}"
|
|
2396
|
+
else:
|
|
2397
|
+
task_line = f" {color}{icon}{Theme.RESET} {content}"
|
|
2398
|
+
indicator = ""
|
|
2399
|
+
|
|
2400
|
+
# Calculate final line visual width
|
|
2401
|
+
v_line_w = self._get_visual_width(task_line)
|
|
2402
|
+
v_indicator_w = self._get_visual_width(indicator)
|
|
2403
|
+
padding = width - v_line_w - v_indicator_w - 1
|
|
2404
|
+
|
|
2405
|
+
print(f"{PLAN_PRIMARY}{Theme.BOX_VERTICAL}{Theme.RESET}{task_line}{indicator}{' ' * max(0, padding)}{PLAN_PRIMARY}{Theme.BOX_VERTICAL}{Theme.RESET}")
|
|
2406
|
+
|
|
2407
|
+
# Footer with action info
|
|
2408
|
+
if current_action and current_task_id:
|
|
2409
|
+
action_icons = {
|
|
2410
|
+
"create": "📝",
|
|
2411
|
+
"start": "▶",
|
|
2412
|
+
"done": "✓",
|
|
2413
|
+
"pause": "⏸",
|
|
2414
|
+
"skip": "⏭",
|
|
2415
|
+
}
|
|
2416
|
+
action_icon = action_icons.get(current_action, "•")
|
|
2417
|
+
|
|
2418
|
+
# Separator
|
|
2419
|
+
print(f"{PLAN_PRIMARY}{Theme.BOX_VERTICAL}{Theme.BOX_HORIZONTAL * (width)}{Theme.BOX_VERTICAL}{Theme.RESET}")
|
|
2420
|
+
|
|
2421
|
+
# Action line
|
|
2422
|
+
if current_task_content:
|
|
2423
|
+
action_text = f" {action_icon} Task {current_task_id}: {current_task_content}"
|
|
2424
|
+
else:
|
|
2425
|
+
action_text = f" {action_icon} Task {current_task_id}"
|
|
2426
|
+
|
|
2427
|
+
v_action_w = self._get_visual_width(action_text)
|
|
2428
|
+
if v_action_w > width - 4:
|
|
2429
|
+
action_text = action_text[:int((width-6)/1.5)] + "..."
|
|
2430
|
+
v_action_w = self._get_visual_width(action_text)
|
|
2431
|
+
|
|
2432
|
+
padding = width - v_action_w - 1
|
|
2433
|
+
print(f"{PLAN_PRIMARY}{Theme.BOX_VERTICAL}{Theme.RESET}{PLAN_ACCENT}{action_text}{Theme.RESET}{' ' * max(0, padding)}{PLAN_PRIMARY}{Theme.BOX_VERTICAL}{Theme.RESET}")
|
|
2434
|
+
|
|
2435
|
+
# Conclusions if present
|
|
2436
|
+
if conclusions:
|
|
2437
|
+
conclusion_text = f" {conclusions}"
|
|
2438
|
+
v_conclusion_w = self._get_visual_width(conclusion_text)
|
|
2439
|
+
if v_conclusion_w > width - 4:
|
|
2440
|
+
conclusion_text = conclusion_text[:int((width-6)/1.5)] + "..."
|
|
2441
|
+
v_conclusion_w = self._get_visual_width(conclusion_text)
|
|
2442
|
+
|
|
2443
|
+
padding = width - v_conclusion_w - 1
|
|
2444
|
+
print(f"{PLAN_PRIMARY}{Theme.BOX_VERTICAL}{Theme.RESET}{PLAN_MUTED}{conclusion_text}{Theme.RESET}{' ' * max(0, padding)}{PLAN_PRIMARY}{Theme.BOX_VERTICAL}{Theme.RESET}")
|
|
2445
|
+
|
|
2446
|
+
# Bottom border with optional timer
|
|
2447
|
+
if elapsed_seconds is not None:
|
|
2448
|
+
timer_text = f" {elapsed_seconds}s "
|
|
2449
|
+
v_timer_w = self._get_visual_width(timer_text)
|
|
2450
|
+
left_len = (width - v_timer_w) // 2
|
|
2451
|
+
right_len = width - v_timer_w - left_len
|
|
2452
|
+
print(f"{PLAN_PRIMARY}{Theme.BOX_BOTTOM_LEFT}{Theme.BOX_HORIZONTAL * left_len}{Theme.RESET}{Theme.MUTED}{timer_text}{Theme.RESET}{PLAN_PRIMARY}{Theme.BOX_HORIZONTAL * right_len}{Theme.BOX_BOTTOM_RIGHT}{Theme.RESET}")
|
|
2453
|
+
else:
|
|
2454
|
+
print(f"{PLAN_PRIMARY}{Theme.BOX_BOTTOM_LEFT}{Theme.BOX_HORIZONTAL * (width)}{Theme.BOX_BOTTOM_RIGHT}{Theme.RESET}")
|
|
2455
|
+
|
|
2456
|
+
# No trailing newline - let following content add spacing as needed
|
|
2457
|
+
|
|
2458
|
+
# ─────────────────────────────────────────────────────────────
|
|
2459
|
+
# Task List Display (Original)
|
|
2460
|
+
# ─────────────────────────────────────────────────────────────
|
|
2461
|
+
|
|
2462
|
+
def task_list(
|
|
2463
|
+
self,
|
|
2464
|
+
tasks: List[Dict[str, Any]],
|
|
2465
|
+
title: str = "📋 Task Plan",
|
|
2466
|
+
verbose: Optional[bool] = None
|
|
2467
|
+
) -> None:
|
|
2468
|
+
"""
|
|
2469
|
+
Display a formatted task list with status icons.
|
|
2470
|
+
|
|
2471
|
+
Args:
|
|
2472
|
+
tasks: List of tasks with 'name', 'status' ('pending', 'running', 'done', 'error')
|
|
2473
|
+
title: Title for the task list
|
|
2474
|
+
verbose: Override verbose setting
|
|
2475
|
+
"""
|
|
2476
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
2477
|
+
return
|
|
2478
|
+
|
|
2479
|
+
# Status icons and colors
|
|
2480
|
+
status_display = {
|
|
2481
|
+
"pending": (Theme.MUTED, "⏳"),
|
|
2482
|
+
"running": (Theme.PRIMARY, "⚡"),
|
|
2483
|
+
"done": (Theme.SUCCESS, "✓"),
|
|
2484
|
+
"completed": (Theme.SUCCESS, "✓"),
|
|
2485
|
+
"success": (Theme.SUCCESS, "✓"),
|
|
2486
|
+
"error": (Theme.ERROR, "✗"),
|
|
2487
|
+
"failed": (Theme.ERROR, "✗"),
|
|
2488
|
+
"skipped": (Theme.WARNING, "○"),
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
# Count stats
|
|
2492
|
+
done_count = sum(1 for t in tasks if t.get("status") in ("done", "completed", "success"))
|
|
2493
|
+
total_count = len(tasks)
|
|
2494
|
+
|
|
2495
|
+
# Header with progress
|
|
2496
|
+
percentage = (done_count / total_count * 100) if total_count > 0 else 0
|
|
2497
|
+
header = f"{Theme.BOLD}{title}{Theme.RESET} {Theme.MUTED}({done_count}/{total_count} · {percentage:.0f}%){Theme.RESET}"
|
|
2498
|
+
|
|
2499
|
+
print(f"\n{header}")
|
|
2500
|
+
|
|
2501
|
+
# Task items
|
|
2502
|
+
for i, task in enumerate(tasks, 1):
|
|
2503
|
+
name = task.get("name", task.get("description", f"Task {i}"))
|
|
2504
|
+
status = task.get("status", "pending").lower()
|
|
2505
|
+
color, icon = status_display.get(status, (Theme.MUTED, "○"))
|
|
2506
|
+
|
|
2507
|
+
# Highlight current running task
|
|
2508
|
+
if status == "running":
|
|
2509
|
+
task_line = f" {color}{icon} {Theme.BOLD}{i}. {name}{Theme.RESET}"
|
|
2510
|
+
else:
|
|
2511
|
+
task_line = f" {color}{icon}{Theme.RESET} {i}. {name}"
|
|
2512
|
+
|
|
2513
|
+
print(task_line)
|
|
2514
|
+
|
|
2515
|
+
def plan_summary(
|
|
2516
|
+
self,
|
|
2517
|
+
plan_name: str,
|
|
2518
|
+
tasks: List[str],
|
|
2519
|
+
next_step: Optional[str] = None,
|
|
2520
|
+
verbose: Optional[bool] = None
|
|
2521
|
+
) -> None:
|
|
2522
|
+
"""
|
|
2523
|
+
Display a plan summary in a clean card format.
|
|
2524
|
+
|
|
2525
|
+
Args:
|
|
2526
|
+
plan_name: Name/title of the plan
|
|
2527
|
+
tasks: List of task descriptions
|
|
2528
|
+
next_step: Next step to execute
|
|
2529
|
+
verbose: Override verbose setting
|
|
2530
|
+
"""
|
|
2531
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
2532
|
+
return
|
|
2533
|
+
|
|
2534
|
+
print(f"\n{self._draw_box_top(plan_name, '📋 ', StatusType.SUCCESS)}")
|
|
2535
|
+
|
|
2536
|
+
# Tasks section
|
|
2537
|
+
print(self._draw_box_line(f"{Theme.LABEL}Tasks ({len(tasks)}):{Theme.RESET}"))
|
|
2538
|
+
for i, task in enumerate(tasks[:7], 1): # Show max 7 tasks
|
|
2539
|
+
task_preview = self._truncate(task, 50)
|
|
2540
|
+
print(self._draw_box_line(f" {Theme.MUTED}{i}.{Theme.RESET} {task_preview}"))
|
|
2541
|
+
|
|
2542
|
+
if len(tasks) > 7:
|
|
2543
|
+
print(self._draw_box_line(f" {Theme.MUTED}...+{len(tasks) - 7} more{Theme.RESET}"))
|
|
2544
|
+
|
|
2545
|
+
# Next step section
|
|
2546
|
+
if next_step:
|
|
2547
|
+
print(self._draw_separator("light"))
|
|
2548
|
+
print(self._draw_box_line(f"{Theme.LABEL}Next:{Theme.RESET} {Theme.PRIMARY}{next_step}{Theme.RESET}"))
|
|
2549
|
+
|
|
2550
|
+
print(self._draw_box_bottom())
|
|
2551
|
+
|
|
2552
|
+
def result_card(
|
|
2553
|
+
self,
|
|
2554
|
+
title: str,
|
|
2555
|
+
content: Any,
|
|
2556
|
+
status: str = "success",
|
|
2557
|
+
verbose: Optional[bool] = None
|
|
2558
|
+
) -> None:
|
|
2559
|
+
"""
|
|
2560
|
+
Display a result in a card format.
|
|
2561
|
+
|
|
2562
|
+
Args:
|
|
2563
|
+
title: Title of the result
|
|
2564
|
+
content: Result content (str, dict, or list)
|
|
2565
|
+
status: Status ('success', 'error', 'info')
|
|
2566
|
+
verbose: Override verbose setting
|
|
2567
|
+
"""
|
|
2568
|
+
if verbose is False or (verbose is None and not self.verbose):
|
|
2569
|
+
return
|
|
2570
|
+
|
|
2571
|
+
status_map = {
|
|
2572
|
+
"success": (StatusType.SUCCESS, "✓"),
|
|
2573
|
+
"error": (StatusType.ERROR, "✗"),
|
|
2574
|
+
"info": (StatusType.RUNNING, "ℹ"),
|
|
2575
|
+
}
|
|
2576
|
+
status_type, icon = status_map.get(status, (StatusType.SUCCESS, "✓"))
|
|
2577
|
+
|
|
2578
|
+
print(f"\n{self._draw_box_top(title, f'{icon} ', status_type)}")
|
|
2579
|
+
|
|
2580
|
+
# Format content
|
|
2581
|
+
if isinstance(content, dict):
|
|
2582
|
+
formatted = self._format_compact_json(content)
|
|
2583
|
+
elif isinstance(content, list):
|
|
2584
|
+
formatted = self._format_compact_json(content)
|
|
2585
|
+
else:
|
|
2586
|
+
formatted = str(content)
|
|
2587
|
+
|
|
2588
|
+
for line in formatted.split("\n")[:15]: # Limit to 15 lines
|
|
2589
|
+
print(self._draw_box_line(f" {line}"))
|
|
2590
|
+
|
|
2591
|
+
if formatted.count("\n") > 15:
|
|
2592
|
+
print(self._draw_box_line(f" {Theme.MUTED}...{Theme.RESET}"))
|
|
2593
|
+
|
|
2594
|
+
print(self._draw_box_bottom())
|
|
2595
|
+
|
|
2596
|
+
|
|
2597
|
+
# ─────────────────────────────────────────────────────────────
|
|
2598
|
+
# Global Instance and Convenience Functions
|
|
2599
|
+
# ─────────────────────────────────────────────────────────────
|
|
2600
|
+
|
|
2601
|
+
# Global console UI instance
|
|
2602
|
+
_console_ui: Optional[ConsoleUI] = None
|
|
2603
|
+
|
|
2604
|
+
|
|
2605
|
+
def get_console_ui(verbose: bool = True) -> ConsoleUI:
|
|
2606
|
+
"""Get or create global ConsoleUI instance"""
|
|
2607
|
+
global _console_ui
|
|
2608
|
+
if _console_ui is None:
|
|
2609
|
+
_console_ui = ConsoleUI(verbose=verbose)
|
|
2610
|
+
return _console_ui
|
|
2611
|
+
|
|
2612
|
+
|
|
2613
|
+
def set_console_ui(ui: ConsoleUI) -> None:
|
|
2614
|
+
"""Set global ConsoleUI instance"""
|
|
2615
|
+
global _console_ui
|
|
2616
|
+
_console_ui = ui
|
|
2617
|
+
|
|
2618
|
+
|
|
2619
|
+
# Convenience functions that delegate to global instance
|
|
2620
|
+
def ui_skill_call(
|
|
2621
|
+
skill_name: str,
|
|
2622
|
+
params: Dict[str, Any],
|
|
2623
|
+
max_length: int = 300,
|
|
2624
|
+
verbose: Optional[bool] = None
|
|
2625
|
+
) -> None:
|
|
2626
|
+
"""Display skill call start (convenience function)"""
|
|
2627
|
+
get_console_ui().skill_call_start(skill_name, params, max_length, verbose)
|
|
2628
|
+
|
|
2629
|
+
|
|
2630
|
+
def ui_skill_response(
|
|
2631
|
+
skill_name: str,
|
|
2632
|
+
response: Any,
|
|
2633
|
+
max_length: int = 300,
|
|
2634
|
+
success: bool = True,
|
|
2635
|
+
duration_ms: Optional[float] = None,
|
|
2636
|
+
verbose: Optional[bool] = None
|
|
2637
|
+
) -> None:
|
|
2638
|
+
"""Display skill call response (convenience function)"""
|
|
2639
|
+
get_console_ui().skill_call_end(skill_name, response, max_length, success, duration_ms, verbose)
|
|
2640
|
+
|
|
2641
|
+
|
|
2642
|
+
def ui_block_start(
|
|
2643
|
+
block_type: str,
|
|
2644
|
+
output_var: str,
|
|
2645
|
+
content: Optional[str] = None,
|
|
2646
|
+
verbose: Optional[bool] = None
|
|
2647
|
+
) -> None:
|
|
2648
|
+
"""Display block start (convenience function)"""
|
|
2649
|
+
get_console_ui().block_start(block_type, output_var, content, verbose)
|
|
2650
|
+
|
|
2651
|
+
|
|
2652
|
+
# Session and conversation display functions (moved from log.py)
|
|
2653
|
+
def console_session_start(session_type: str, target: str) -> None:
|
|
2654
|
+
"""Display session start message"""
|
|
2655
|
+
get_console_ui().session_start(session_type, target, verbose=True)
|
|
2656
|
+
|
|
2657
|
+
|
|
2658
|
+
def console_session_end() -> None:
|
|
2659
|
+
"""Display session end message"""
|
|
2660
|
+
get_console_ui().session_end(verbose=True)
|
|
2661
|
+
|
|
2662
|
+
|
|
2663
|
+
# Alias for backwards compatibility
|
|
2664
|
+
console_conversation_end = console_session_end
|
|
2665
|
+
|
|
2666
|
+
|
|
2667
|
+
def console_display_session_info(skillkit_info: dict = None, show_commands: bool = True) -> None:
|
|
2668
|
+
"""Display available skillkits and command hints after session start.
|
|
2669
|
+
|
|
2670
|
+
Args:
|
|
2671
|
+
skillkit_info: Dict mapping skillkit name to tool count, e.g. {"python_skillkit": 3}
|
|
2672
|
+
show_commands: Whether to show available slash commands
|
|
2673
|
+
"""
|
|
2674
|
+
get_console_ui().display_session_info(skillkit_info, show_commands, verbose=True)
|
|
2675
|
+
|
|
2676
|
+
|
|
2677
|
+
def console_user_input(user_input: str) -> None:
|
|
2678
|
+
"""Display user input in enhanced format"""
|
|
2679
|
+
get_console_ui().user_input_display(user_input, verbose=True)
|
|
2680
|
+
|
|
2681
|
+
|
|
2682
|
+
def console_conversation_separator() -> None:
|
|
2683
|
+
"""Display conversation separator line"""
|
|
2684
|
+
separator = f"{Theme.MUTED}{Theme.BOX_HORIZONTAL * 40}{Theme.RESET}"
|
|
2685
|
+
print(f"\n{separator}")
|
|
2686
|
+
|
|
2687
|
+
|
|
2688
|
+
if __name__ == "__main__":
|
|
2689
|
+
# Demo the UI
|
|
2690
|
+
ui = ConsoleUI()
|
|
2691
|
+
|
|
2692
|
+
print("\n" + "=" * 60)
|
|
2693
|
+
print(" Dolphin Console UI Demo")
|
|
2694
|
+
print("=" * 60)
|
|
2695
|
+
|
|
2696
|
+
# ─────────────────────────────────────────────────────────────
|
|
2697
|
+
# NEW: Codex-Style Components Demo
|
|
2698
|
+
# ─────────────────────────────────────────────────────────────
|
|
2699
|
+
|
|
2700
|
+
print("\n" + "-" * 60)
|
|
2701
|
+
print(" [NEW] Codex-Style Components")
|
|
2702
|
+
print("-" * 60)
|
|
2703
|
+
|
|
2704
|
+
# Demo Codex-style task list tree
|
|
2705
|
+
ui.task_list_tree(
|
|
2706
|
+
[
|
|
2707
|
+
{"content": "扫描仓库结构与说明文档", "status": "completed"},
|
|
2708
|
+
{"content": "梳理核心模块与依赖关系", "status": "completed"},
|
|
2709
|
+
{"content": "总结构建运行与开发流程", "status": "in_progress"},
|
|
2710
|
+
{"content": "给出可继续深入的阅读路径", "status": "pending"},
|
|
2711
|
+
],
|
|
2712
|
+
title="Updated Plan",
|
|
2713
|
+
style="codex"
|
|
2714
|
+
)
|
|
2715
|
+
|
|
2716
|
+
# Demo action item (like Codex's "Explored", "Ran" sections)
|
|
2717
|
+
ui.action_item(
|
|
2718
|
+
action="Explored",
|
|
2719
|
+
description="List ls -la",
|
|
2720
|
+
details=[
|
|
2721
|
+
"Search AGENTS.md in ..",
|
|
2722
|
+
"List ls -la"
|
|
2723
|
+
]
|
|
2724
|
+
)
|
|
2725
|
+
|
|
2726
|
+
ui.action_item(
|
|
2727
|
+
action="Ran",
|
|
2728
|
+
description='git rev-parse --is-inside-work-tree && git log -n 5',
|
|
2729
|
+
details=[
|
|
2730
|
+
"true",
|
|
2731
|
+
"… +3 lines",
|
|
2732
|
+
"c91c032d 已合并 PR 140381: examples清理",
|
|
2733
|
+
"229ae14c 已合并 PR 139859: 核心代码注释翻译英文"
|
|
2734
|
+
]
|
|
2735
|
+
)
|
|
2736
|
+
|
|
2737
|
+
# Demo timed status (like Codex's bottom status bar)
|
|
2738
|
+
ui.timed_status("Planning repository inspection", elapsed_seconds=27)
|
|
2739
|
+
|
|
2740
|
+
# Demo collapsible text
|
|
2741
|
+
long_text = "\n".join([f"Line {i}: Some content here..." for i in range(1, 80)])
|
|
2742
|
+
print("\n[Collapsible Text Demo]")
|
|
2743
|
+
ui.print_collapsible(long_text, max_lines=5)
|
|
2744
|
+
|
|
2745
|
+
print("\n" + "-" * 60)
|
|
2746
|
+
print(" [END] Codex-Style Components")
|
|
2747
|
+
print("-" * 60)
|
|
2748
|
+
|
|
2749
|
+
# ─────────────────────────────────────────────────────────────
|
|
2750
|
+
# Original Demo
|
|
2751
|
+
# ─────────────────────────────────────────────────────────────
|
|
2752
|
+
|
|
2753
|
+
print("\n" + "-" * 60)
|
|
2754
|
+
print(" Original Components")
|
|
2755
|
+
print("-" * 60)
|
|
2756
|
+
|
|
2757
|
+
# Demo skill call
|
|
2758
|
+
ui.skill_call_start(
|
|
2759
|
+
"_plan_act",
|
|
2760
|
+
{
|
|
2761
|
+
"planningMode": "create",
|
|
2762
|
+
"taskList": "1. Load Excel file\n2. Analyze structure\n3. Generate insights"
|
|
2763
|
+
}
|
|
2764
|
+
)
|
|
2765
|
+
|
|
2766
|
+
import time
|
|
2767
|
+
time.sleep(0.5)
|
|
2768
|
+
|
|
2769
|
+
ui.skill_call_end(
|
|
2770
|
+
"_plan_act",
|
|
2771
|
+
{
|
|
2772
|
+
"status": "success",
|
|
2773
|
+
"tasks": ["Task 1", "Task 2", "Task 3"],
|
|
2774
|
+
"nextStep": "Execute Task 1"
|
|
2775
|
+
},
|
|
2776
|
+
duration_ms=156.3
|
|
2777
|
+
)
|
|
2778
|
+
|
|
2779
|
+
# Demo block start
|
|
2780
|
+
ui.block_start("explore", "analysis_result", "Analyzing the data structure...")
|
|
2781
|
+
|
|
2782
|
+
# Demo compact call
|
|
2783
|
+
ui.skill_call_compact(
|
|
2784
|
+
"_python",
|
|
2785
|
+
{"code": "df.describe()"},
|
|
2786
|
+
"DataFrame statistics computed",
|
|
2787
|
+
success=True,
|
|
2788
|
+
duration_ms=42.5
|
|
2789
|
+
)
|
|
2790
|
+
|
|
2791
|
+
# Demo session
|
|
2792
|
+
ui.session_start("interactive", "tabular_analyst")
|
|
2793
|
+
ui.user_input_display("Analyze this Excel file")
|
|
2794
|
+
ui.agent_label("tabular_analyst")
|
|
2795
|
+
ui.session_end()
|