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.
Files changed (199) hide show
  1. DolphinLanguageSDK/__init__.py +58 -0
  2. dolphin/__init__.py +62 -0
  3. dolphin/cli/__init__.py +20 -0
  4. dolphin/cli/args/__init__.py +9 -0
  5. dolphin/cli/args/parser.py +567 -0
  6. dolphin/cli/builtin_agents/__init__.py +22 -0
  7. dolphin/cli/commands/__init__.py +4 -0
  8. dolphin/cli/interrupt/__init__.py +8 -0
  9. dolphin/cli/interrupt/handler.py +205 -0
  10. dolphin/cli/interrupt/keyboard.py +82 -0
  11. dolphin/cli/main.py +49 -0
  12. dolphin/cli/multimodal/__init__.py +34 -0
  13. dolphin/cli/multimodal/clipboard.py +327 -0
  14. dolphin/cli/multimodal/handler.py +249 -0
  15. dolphin/cli/multimodal/image_processor.py +214 -0
  16. dolphin/cli/multimodal/input_parser.py +149 -0
  17. dolphin/cli/runner/__init__.py +8 -0
  18. dolphin/cli/runner/runner.py +989 -0
  19. dolphin/cli/ui/__init__.py +10 -0
  20. dolphin/cli/ui/console.py +2795 -0
  21. dolphin/cli/ui/input.py +340 -0
  22. dolphin/cli/ui/layout.py +425 -0
  23. dolphin/cli/ui/stream_renderer.py +302 -0
  24. dolphin/cli/utils/__init__.py +8 -0
  25. dolphin/cli/utils/helpers.py +135 -0
  26. dolphin/cli/utils/version.py +49 -0
  27. dolphin/core/__init__.py +107 -0
  28. dolphin/core/agent/__init__.py +10 -0
  29. dolphin/core/agent/agent_state.py +69 -0
  30. dolphin/core/agent/base_agent.py +970 -0
  31. dolphin/core/code_block/__init__.py +0 -0
  32. dolphin/core/code_block/agent_init_block.py +0 -0
  33. dolphin/core/code_block/assign_block.py +98 -0
  34. dolphin/core/code_block/basic_code_block.py +1865 -0
  35. dolphin/core/code_block/explore_block.py +1327 -0
  36. dolphin/core/code_block/explore_block_v2.py +712 -0
  37. dolphin/core/code_block/explore_strategy.py +672 -0
  38. dolphin/core/code_block/judge_block.py +220 -0
  39. dolphin/core/code_block/prompt_block.py +32 -0
  40. dolphin/core/code_block/skill_call_deduplicator.py +291 -0
  41. dolphin/core/code_block/tool_block.py +129 -0
  42. dolphin/core/common/__init__.py +17 -0
  43. dolphin/core/common/constants.py +176 -0
  44. dolphin/core/common/enums.py +1173 -0
  45. dolphin/core/common/exceptions.py +133 -0
  46. dolphin/core/common/multimodal.py +539 -0
  47. dolphin/core/common/object_type.py +165 -0
  48. dolphin/core/common/output_format.py +432 -0
  49. dolphin/core/common/types.py +36 -0
  50. dolphin/core/config/__init__.py +16 -0
  51. dolphin/core/config/global_config.py +1289 -0
  52. dolphin/core/config/ontology_config.py +133 -0
  53. dolphin/core/context/__init__.py +12 -0
  54. dolphin/core/context/context.py +1580 -0
  55. dolphin/core/context/context_manager.py +161 -0
  56. dolphin/core/context/var_output.py +82 -0
  57. dolphin/core/context/variable_pool.py +356 -0
  58. dolphin/core/context_engineer/__init__.py +41 -0
  59. dolphin/core/context_engineer/config/__init__.py +5 -0
  60. dolphin/core/context_engineer/config/settings.py +402 -0
  61. dolphin/core/context_engineer/core/__init__.py +7 -0
  62. dolphin/core/context_engineer/core/budget_manager.py +327 -0
  63. dolphin/core/context_engineer/core/context_assembler.py +583 -0
  64. dolphin/core/context_engineer/core/context_manager.py +637 -0
  65. dolphin/core/context_engineer/core/tokenizer_service.py +260 -0
  66. dolphin/core/context_engineer/example/incremental_example.py +267 -0
  67. dolphin/core/context_engineer/example/traditional_example.py +334 -0
  68. dolphin/core/context_engineer/services/__init__.py +5 -0
  69. dolphin/core/context_engineer/services/compressor.py +399 -0
  70. dolphin/core/context_engineer/utils/__init__.py +6 -0
  71. dolphin/core/context_engineer/utils/context_utils.py +441 -0
  72. dolphin/core/context_engineer/utils/message_formatter.py +270 -0
  73. dolphin/core/context_engineer/utils/token_utils.py +139 -0
  74. dolphin/core/coroutine/__init__.py +15 -0
  75. dolphin/core/coroutine/context_snapshot.py +154 -0
  76. dolphin/core/coroutine/context_snapshot_profile.py +922 -0
  77. dolphin/core/coroutine/context_snapshot_store.py +268 -0
  78. dolphin/core/coroutine/execution_frame.py +145 -0
  79. dolphin/core/coroutine/execution_state_registry.py +161 -0
  80. dolphin/core/coroutine/resume_handle.py +101 -0
  81. dolphin/core/coroutine/step_result.py +101 -0
  82. dolphin/core/executor/__init__.py +18 -0
  83. dolphin/core/executor/debug_controller.py +630 -0
  84. dolphin/core/executor/dolphin_executor.py +1063 -0
  85. dolphin/core/executor/executor.py +624 -0
  86. dolphin/core/flags/__init__.py +27 -0
  87. dolphin/core/flags/definitions.py +49 -0
  88. dolphin/core/flags/manager.py +113 -0
  89. dolphin/core/hook/__init__.py +95 -0
  90. dolphin/core/hook/expression_evaluator.py +499 -0
  91. dolphin/core/hook/hook_dispatcher.py +380 -0
  92. dolphin/core/hook/hook_types.py +248 -0
  93. dolphin/core/hook/isolated_variable_pool.py +284 -0
  94. dolphin/core/interfaces.py +53 -0
  95. dolphin/core/llm/__init__.py +0 -0
  96. dolphin/core/llm/llm.py +495 -0
  97. dolphin/core/llm/llm_call.py +100 -0
  98. dolphin/core/llm/llm_client.py +1285 -0
  99. dolphin/core/llm/message_sanitizer.py +120 -0
  100. dolphin/core/logging/__init__.py +20 -0
  101. dolphin/core/logging/logger.py +526 -0
  102. dolphin/core/message/__init__.py +8 -0
  103. dolphin/core/message/compressor.py +749 -0
  104. dolphin/core/parser/__init__.py +8 -0
  105. dolphin/core/parser/parser.py +405 -0
  106. dolphin/core/runtime/__init__.py +10 -0
  107. dolphin/core/runtime/runtime_graph.py +926 -0
  108. dolphin/core/runtime/runtime_instance.py +446 -0
  109. dolphin/core/skill/__init__.py +14 -0
  110. dolphin/core/skill/context_retention.py +157 -0
  111. dolphin/core/skill/skill_function.py +686 -0
  112. dolphin/core/skill/skill_matcher.py +282 -0
  113. dolphin/core/skill/skillkit.py +700 -0
  114. dolphin/core/skill/skillset.py +72 -0
  115. dolphin/core/trajectory/__init__.py +10 -0
  116. dolphin/core/trajectory/recorder.py +189 -0
  117. dolphin/core/trajectory/trajectory.py +522 -0
  118. dolphin/core/utils/__init__.py +9 -0
  119. dolphin/core/utils/cache_kv.py +212 -0
  120. dolphin/core/utils/tools.py +340 -0
  121. dolphin/lib/__init__.py +93 -0
  122. dolphin/lib/debug/__init__.py +8 -0
  123. dolphin/lib/debug/visualizer.py +409 -0
  124. dolphin/lib/memory/__init__.py +28 -0
  125. dolphin/lib/memory/async_processor.py +220 -0
  126. dolphin/lib/memory/llm_calls.py +195 -0
  127. dolphin/lib/memory/manager.py +78 -0
  128. dolphin/lib/memory/sandbox.py +46 -0
  129. dolphin/lib/memory/storage.py +245 -0
  130. dolphin/lib/memory/utils.py +51 -0
  131. dolphin/lib/ontology/__init__.py +12 -0
  132. dolphin/lib/ontology/basic/__init__.py +0 -0
  133. dolphin/lib/ontology/basic/base.py +102 -0
  134. dolphin/lib/ontology/basic/concept.py +130 -0
  135. dolphin/lib/ontology/basic/object.py +11 -0
  136. dolphin/lib/ontology/basic/relation.py +63 -0
  137. dolphin/lib/ontology/datasource/__init__.py +27 -0
  138. dolphin/lib/ontology/datasource/datasource.py +66 -0
  139. dolphin/lib/ontology/datasource/oracle_datasource.py +338 -0
  140. dolphin/lib/ontology/datasource/sql.py +845 -0
  141. dolphin/lib/ontology/mapping.py +177 -0
  142. dolphin/lib/ontology/ontology.py +733 -0
  143. dolphin/lib/ontology/ontology_context.py +16 -0
  144. dolphin/lib/ontology/ontology_manager.py +107 -0
  145. dolphin/lib/skill_results/__init__.py +31 -0
  146. dolphin/lib/skill_results/cache_backend.py +559 -0
  147. dolphin/lib/skill_results/result_processor.py +181 -0
  148. dolphin/lib/skill_results/result_reference.py +179 -0
  149. dolphin/lib/skill_results/skillkit_hook.py +324 -0
  150. dolphin/lib/skill_results/strategies.py +328 -0
  151. dolphin/lib/skill_results/strategy_registry.py +150 -0
  152. dolphin/lib/skillkits/__init__.py +44 -0
  153. dolphin/lib/skillkits/agent_skillkit.py +155 -0
  154. dolphin/lib/skillkits/cognitive_skillkit.py +82 -0
  155. dolphin/lib/skillkits/env_skillkit.py +250 -0
  156. dolphin/lib/skillkits/mcp_adapter.py +616 -0
  157. dolphin/lib/skillkits/mcp_skillkit.py +771 -0
  158. dolphin/lib/skillkits/memory_skillkit.py +650 -0
  159. dolphin/lib/skillkits/noop_skillkit.py +31 -0
  160. dolphin/lib/skillkits/ontology_skillkit.py +89 -0
  161. dolphin/lib/skillkits/plan_act_skillkit.py +452 -0
  162. dolphin/lib/skillkits/resource/__init__.py +52 -0
  163. dolphin/lib/skillkits/resource/models/__init__.py +6 -0
  164. dolphin/lib/skillkits/resource/models/skill_config.py +109 -0
  165. dolphin/lib/skillkits/resource/models/skill_meta.py +127 -0
  166. dolphin/lib/skillkits/resource/resource_skillkit.py +393 -0
  167. dolphin/lib/skillkits/resource/skill_cache.py +215 -0
  168. dolphin/lib/skillkits/resource/skill_loader.py +395 -0
  169. dolphin/lib/skillkits/resource/skill_validator.py +406 -0
  170. dolphin/lib/skillkits/resource_skillkit.py +11 -0
  171. dolphin/lib/skillkits/search_skillkit.py +163 -0
  172. dolphin/lib/skillkits/sql_skillkit.py +274 -0
  173. dolphin/lib/skillkits/system_skillkit.py +509 -0
  174. dolphin/lib/skillkits/vm_skillkit.py +65 -0
  175. dolphin/lib/utils/__init__.py +9 -0
  176. dolphin/lib/utils/data_process.py +207 -0
  177. dolphin/lib/utils/handle_progress.py +178 -0
  178. dolphin/lib/utils/security.py +139 -0
  179. dolphin/lib/utils/text_retrieval.py +462 -0
  180. dolphin/lib/vm/__init__.py +11 -0
  181. dolphin/lib/vm/env_executor.py +895 -0
  182. dolphin/lib/vm/python_session_manager.py +453 -0
  183. dolphin/lib/vm/vm.py +610 -0
  184. dolphin/sdk/__init__.py +60 -0
  185. dolphin/sdk/agent/__init__.py +12 -0
  186. dolphin/sdk/agent/agent_factory.py +236 -0
  187. dolphin/sdk/agent/dolphin_agent.py +1106 -0
  188. dolphin/sdk/api/__init__.py +4 -0
  189. dolphin/sdk/runtime/__init__.py +8 -0
  190. dolphin/sdk/runtime/env.py +363 -0
  191. dolphin/sdk/skill/__init__.py +10 -0
  192. dolphin/sdk/skill/global_skills.py +706 -0
  193. dolphin/sdk/skill/traditional_toolkit.py +260 -0
  194. kweaver_dolphin-0.1.0.dist-info/METADATA +521 -0
  195. kweaver_dolphin-0.1.0.dist-info/RECORD +199 -0
  196. kweaver_dolphin-0.1.0.dist-info/WHEEL +5 -0
  197. kweaver_dolphin-0.1.0.dist-info/entry_points.txt +27 -0
  198. kweaver_dolphin-0.1.0.dist-info/licenses/LICENSE.txt +201 -0
  199. 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()