emdash-cli 0.1.46__py3-none-any.whl → 0.1.70__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.
- emdash_cli/client.py +12 -28
- emdash_cli/commands/__init__.py +2 -2
- emdash_cli/commands/agent/constants.py +78 -0
- emdash_cli/commands/agent/handlers/__init__.py +10 -0
- emdash_cli/commands/agent/handlers/agents.py +67 -39
- emdash_cli/commands/agent/handlers/index.py +183 -0
- emdash_cli/commands/agent/handlers/misc.py +119 -0
- emdash_cli/commands/agent/handlers/registry.py +72 -0
- emdash_cli/commands/agent/handlers/rules.py +48 -31
- emdash_cli/commands/agent/handlers/sessions.py +1 -1
- emdash_cli/commands/agent/handlers/setup.py +187 -54
- emdash_cli/commands/agent/handlers/skills.py +42 -4
- emdash_cli/commands/agent/handlers/telegram.py +523 -0
- emdash_cli/commands/agent/handlers/todos.py +55 -34
- emdash_cli/commands/agent/handlers/verify.py +10 -5
- emdash_cli/commands/agent/help.py +236 -0
- emdash_cli/commands/agent/interactive.py +278 -47
- emdash_cli/commands/agent/menus.py +116 -84
- emdash_cli/commands/agent/onboarding.py +619 -0
- emdash_cli/commands/agent/session_restore.py +210 -0
- emdash_cli/commands/index.py +111 -13
- emdash_cli/commands/registry.py +635 -0
- emdash_cli/commands/skills.py +72 -6
- emdash_cli/design.py +328 -0
- emdash_cli/diff_renderer.py +438 -0
- emdash_cli/integrations/__init__.py +1 -0
- emdash_cli/integrations/telegram/__init__.py +15 -0
- emdash_cli/integrations/telegram/bot.py +402 -0
- emdash_cli/integrations/telegram/bridge.py +980 -0
- emdash_cli/integrations/telegram/config.py +155 -0
- emdash_cli/integrations/telegram/formatter.py +392 -0
- emdash_cli/main.py +52 -2
- emdash_cli/sse_renderer.py +632 -171
- {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/METADATA +2 -2
- emdash_cli-0.1.70.dist-info/RECORD +63 -0
- emdash_cli/commands/swarm.py +0 -86
- emdash_cli-0.1.46.dist-info/RECORD +0 -49
- {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/WHEEL +0 -0
- {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/entry_points.txt +0 -0
emdash_cli/sse_renderer.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""SSE event renderer for Rich terminal output."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
import sys
|
|
6
|
+
import textwrap
|
|
5
7
|
import time
|
|
6
8
|
import threading
|
|
7
9
|
from typing import Iterator, Optional
|
|
@@ -11,9 +13,24 @@ from rich.markdown import Markdown
|
|
|
11
13
|
from rich.panel import Panel
|
|
12
14
|
from rich.text import Text
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
from .design import (
|
|
17
|
+
SPINNER_FRAMES,
|
|
18
|
+
Colors,
|
|
19
|
+
ANSI,
|
|
20
|
+
STATUS_ACTIVE,
|
|
21
|
+
STATUS_INACTIVE,
|
|
22
|
+
STATUS_ERROR,
|
|
23
|
+
STATUS_INFO,
|
|
24
|
+
DOT_ACTIVE,
|
|
25
|
+
DOT_BULLET,
|
|
26
|
+
NEST_LINE,
|
|
27
|
+
ARROW_RIGHT,
|
|
28
|
+
header,
|
|
29
|
+
footer,
|
|
30
|
+
progress_bar,
|
|
31
|
+
EM_DASH,
|
|
32
|
+
)
|
|
33
|
+
from .diff_renderer import render_file_change
|
|
17
34
|
|
|
18
35
|
|
|
19
36
|
class SSERenderer:
|
|
@@ -50,6 +67,7 @@ class SSERenderer:
|
|
|
50
67
|
self._current_tool = None
|
|
51
68
|
self._tool_count = 0
|
|
52
69
|
self._completed_tools: list[dict] = []
|
|
70
|
+
self._pending_tools: dict = {} # For parallel tool execution support
|
|
53
71
|
self._spinner_idx = 0
|
|
54
72
|
self._waiting_for_next = False
|
|
55
73
|
|
|
@@ -71,6 +89,19 @@ class SSERenderer:
|
|
|
71
89
|
# Context frame storage (rendered at end of stream)
|
|
72
90
|
self._last_context_frame: Optional[dict] = None
|
|
73
91
|
|
|
92
|
+
# In-place tool update tracking (Claude Code style)
|
|
93
|
+
self._tool_line_active = False # Whether we have an active tool line to update
|
|
94
|
+
self._current_tool_name = "" # Current tool name (for colored display)
|
|
95
|
+
self._current_tool_args = "" # Current tool args (for muted display)
|
|
96
|
+
self._current_tool_line = "" # Full line for subagent display
|
|
97
|
+
self._action_count = 0 # Total actions executed
|
|
98
|
+
self._error_count = 0 # Total errors
|
|
99
|
+
self._start_time = None # Session start time
|
|
100
|
+
|
|
101
|
+
# Floating todo list state
|
|
102
|
+
self._floating_todos: Optional[list] = None # Current todo list (when active)
|
|
103
|
+
self._todo_panel_height = 0 # Height of the todo panel (for cursor positioning)
|
|
104
|
+
|
|
74
105
|
def render_stream(
|
|
75
106
|
self,
|
|
76
107
|
lines: Iterator[str],
|
|
@@ -129,6 +160,10 @@ class SSERenderer:
|
|
|
129
160
|
finally:
|
|
130
161
|
# Always stop spinner when stream ends
|
|
131
162
|
self._stop_spinner()
|
|
163
|
+
# Finalize any active tool line
|
|
164
|
+
self._finalize_tool_line()
|
|
165
|
+
# Clear floating todos (they stay visible as part of output)
|
|
166
|
+
self._clear_floating_todos()
|
|
132
167
|
|
|
133
168
|
# Render context frame at the end (only once)
|
|
134
169
|
if self.verbose:
|
|
@@ -154,6 +189,14 @@ class SSERenderer:
|
|
|
154
189
|
|
|
155
190
|
self._spinner_message = message
|
|
156
191
|
self._spinner_running = True
|
|
192
|
+
|
|
193
|
+
# Show first frame immediately (don't wait for thread tick)
|
|
194
|
+
if not self._tool_line_active:
|
|
195
|
+
wave_frames = ["●○○○○", "○●○○○", "○○●○○", "○○○●○", "○○○○●"]
|
|
196
|
+
with self._spinner_lock:
|
|
197
|
+
sys.stdout.write(f"\r {ANSI.PRIMARY}─{ANSI.RESET} {ANSI.WARNING}{wave_frames[0]}{ANSI.RESET} {ANSI.MUTED}{message}{ANSI.RESET} ")
|
|
198
|
+
sys.stdout.flush()
|
|
199
|
+
|
|
157
200
|
self._spinner_thread = threading.Thread(target=self._spinner_loop, daemon=True)
|
|
158
201
|
self._spinner_thread.start()
|
|
159
202
|
|
|
@@ -167,18 +210,37 @@ class SSERenderer:
|
|
|
167
210
|
self._spinner_thread.join(timeout=0.2)
|
|
168
211
|
self._spinner_thread = None
|
|
169
212
|
|
|
170
|
-
# Clear the spinner line
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
213
|
+
# Clear the spinner line (only if no tool line is active)
|
|
214
|
+
if not self._tool_line_active:
|
|
215
|
+
with self._spinner_lock:
|
|
216
|
+
sys.stdout.write("\r" + " " * 60 + "\r")
|
|
217
|
+
sys.stdout.flush()
|
|
174
218
|
|
|
175
219
|
def _spinner_loop(self) -> None:
|
|
176
|
-
"""Background thread that animates the spinner."""
|
|
220
|
+
"""Background thread that animates the spinner with bouncing dot."""
|
|
221
|
+
# Alternative: wave dots
|
|
222
|
+
wave_frames = [
|
|
223
|
+
"●○○○○",
|
|
224
|
+
"○●○○○",
|
|
225
|
+
"○○●○○",
|
|
226
|
+
"○○○●○",
|
|
227
|
+
"○○○○●",
|
|
228
|
+
"○○○●○",
|
|
229
|
+
"○○●○○",
|
|
230
|
+
"○●○○○",
|
|
231
|
+
]
|
|
232
|
+
frame_idx = 0
|
|
177
233
|
while self._spinner_running:
|
|
234
|
+
# Don't animate spinner if a tool line is active
|
|
235
|
+
if self._tool_line_active:
|
|
236
|
+
time.sleep(0.1)
|
|
237
|
+
continue
|
|
238
|
+
|
|
178
239
|
with self._spinner_lock:
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
240
|
+
frame_idx = (frame_idx + 1) % len(wave_frames)
|
|
241
|
+
dots = wave_frames[frame_idx]
|
|
242
|
+
# Animated dots with em-dash framing
|
|
243
|
+
sys.stdout.write(f"\r {ANSI.PRIMARY}─{ANSI.RESET} {ANSI.WARNING}{dots}{ANSI.RESET} {ANSI.MUTED}{self._spinner_message}{ANSI.RESET} ")
|
|
182
244
|
sys.stdout.flush()
|
|
183
245
|
time.sleep(0.1)
|
|
184
246
|
|
|
@@ -253,22 +315,22 @@ class SSERenderer:
|
|
|
253
315
|
return
|
|
254
316
|
|
|
255
317
|
agent = data.get("agent_name", "Agent")
|
|
256
|
-
model = data.get("model", "unknown")
|
|
257
|
-
|
|
258
|
-
# Extract model name from full path
|
|
259
|
-
if "/" in model:
|
|
260
|
-
model = model.split("/")[-1]
|
|
261
318
|
|
|
262
319
|
self.console.print()
|
|
263
|
-
self.console.print(f"[bold
|
|
320
|
+
self.console.print(f" [{Colors.PRIMARY} bold]{agent}[/{Colors.PRIMARY} bold]")
|
|
321
|
+
|
|
322
|
+
# Reset counters for Claude Code style tracking
|
|
264
323
|
self._tool_count = 0
|
|
265
324
|
self._completed_tools = []
|
|
325
|
+
self._action_count = 0
|
|
326
|
+
self._error_count = 0
|
|
327
|
+
self._start_time = time.time()
|
|
266
328
|
|
|
267
329
|
# Start spinner while waiting for first tool
|
|
268
330
|
self._start_spinner("thinking")
|
|
269
331
|
|
|
270
332
|
def _render_tool_start(self, data: dict) -> None:
|
|
271
|
-
"""Render tool start event."""
|
|
333
|
+
"""Render tool start event - show spinner line for current tool."""
|
|
272
334
|
if not self.verbose:
|
|
273
335
|
return
|
|
274
336
|
|
|
@@ -279,13 +341,11 @@ class SSERenderer:
|
|
|
279
341
|
subagent_type = data.get("subagent_type")
|
|
280
342
|
|
|
281
343
|
self._tool_count += 1
|
|
344
|
+
self._action_count += 1
|
|
282
345
|
|
|
283
346
|
# Store tool info for result rendering (keyed by tool_id for parallel support)
|
|
284
|
-
if not hasattr(self, '_pending_tools'):
|
|
285
|
-
self._pending_tools = {}
|
|
286
|
-
# Use tool_id if available, otherwise fall back to name
|
|
287
347
|
key = tool_id or name
|
|
288
|
-
self._pending_tools[key] = {"name": name, "args": args, "start_time": time.time(), "tool_id": tool_id}
|
|
348
|
+
self._pending_tools[key] = {"name": name, "args": args, "start_time": time.time(), "tool_id": tool_id, "committed": False}
|
|
289
349
|
self._current_tool = self._pending_tools[key]
|
|
290
350
|
|
|
291
351
|
# Stop spinner when tool starts
|
|
@@ -296,21 +356,25 @@ class SSERenderer:
|
|
|
296
356
|
self._render_agent_spawn_start(args)
|
|
297
357
|
return
|
|
298
358
|
|
|
299
|
-
# Sub-agent events:
|
|
359
|
+
# Sub-agent events: show on single updating line
|
|
300
360
|
if subagent_id:
|
|
301
361
|
self._subagent_tool_count += 1
|
|
302
362
|
self._subagent_current_tool = name
|
|
303
|
-
self.
|
|
363
|
+
args_summary = self._format_tool_args_short(name, args)
|
|
364
|
+
self._show_spinner_line(f"{name}({args_summary})")
|
|
304
365
|
return
|
|
305
366
|
|
|
306
|
-
#
|
|
307
|
-
# This
|
|
367
|
+
# For parallel tool calls: if there's already an active spinner, commit it first
|
|
368
|
+
# This allows multiple tools to be shown stacked
|
|
369
|
+
if self._tool_line_active and self._current_tool_name:
|
|
370
|
+
self._commit_tool_line()
|
|
308
371
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
self.
|
|
312
|
-
spinner = SPINNER_FRAMES[self._spinner_idx]
|
|
372
|
+
# Show tool with spinner (will be finalized when result comes)
|
|
373
|
+
args_summary = self._format_tool_args_short(name, args)
|
|
374
|
+
self._show_tool_spinner(name, args_summary)
|
|
313
375
|
|
|
376
|
+
def _render_subagent_progress(self, agent_type: str, tool_name: str, args: dict) -> None:
|
|
377
|
+
"""Render sub-agent tool call with indentation."""
|
|
314
378
|
# Use stored type if not provided
|
|
315
379
|
agent_type = agent_type or self._subagent_type or "Agent"
|
|
316
380
|
|
|
@@ -319,18 +383,20 @@ class SSERenderer:
|
|
|
319
383
|
if "path" in args:
|
|
320
384
|
path = str(args["path"])
|
|
321
385
|
# Shorten long paths
|
|
322
|
-
if len(path) >
|
|
323
|
-
summary = "..." + path[-
|
|
386
|
+
if len(path) > 60:
|
|
387
|
+
summary = "..." + path[-57:]
|
|
324
388
|
else:
|
|
325
389
|
summary = path
|
|
326
390
|
elif "pattern" in args:
|
|
327
|
-
summary = str(args["pattern"])[:
|
|
391
|
+
summary = str(args["pattern"])[:60]
|
|
392
|
+
elif "query" in args:
|
|
393
|
+
summary = str(args["query"])[:60]
|
|
328
394
|
|
|
329
|
-
#
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
395
|
+
# Print tool call on its own line with indentation (zen style)
|
|
396
|
+
if summary:
|
|
397
|
+
self.console.print(f" [{Colors.DIM}]{NEST_LINE}[/{Colors.DIM}] [{Colors.MUTED}]{DOT_ACTIVE}[/{Colors.MUTED}] {tool_name} [{Colors.DIM}]{summary}[/{Colors.DIM}]")
|
|
398
|
+
else:
|
|
399
|
+
self.console.print(f" [{Colors.DIM}]{NEST_LINE}[/{Colors.DIM}] [{Colors.MUTED}]{DOT_ACTIVE}[/{Colors.MUTED}] {tool_name}")
|
|
334
400
|
|
|
335
401
|
def _render_agent_spawn_start(self, args: dict) -> None:
|
|
336
402
|
"""Track sub-agent spawn state (rendering done by subagent_start event)."""
|
|
@@ -348,19 +414,54 @@ class SSERenderer:
|
|
|
348
414
|
# Don't render here - subagent_start event will render the UI
|
|
349
415
|
|
|
350
416
|
def _render_tool_result(self, data: dict) -> None:
|
|
351
|
-
"""Render tool result
|
|
417
|
+
"""Render tool result - finalize the tool line with result."""
|
|
352
418
|
name = data.get("name", "unknown")
|
|
353
419
|
success = data.get("success", True)
|
|
354
420
|
summary = data.get("summary")
|
|
355
421
|
subagent_id = data.get("subagent_id")
|
|
356
422
|
|
|
423
|
+
# Track errors
|
|
424
|
+
if not success:
|
|
425
|
+
self._error_count += 1
|
|
426
|
+
|
|
357
427
|
# Detect spec submission
|
|
358
428
|
if name == "submit_spec" and success:
|
|
359
429
|
self._spec_submitted = True
|
|
360
|
-
spec_data = data.get("data"
|
|
430
|
+
spec_data = data.get("data") or {}
|
|
361
431
|
if spec_data:
|
|
362
432
|
self._spec = spec_data.get("content")
|
|
363
433
|
|
|
434
|
+
# Get tool info early (needed for committed check)
|
|
435
|
+
pending_tools = getattr(self, '_pending_tools', {})
|
|
436
|
+
tool_id = data.get("tool_id")
|
|
437
|
+
key = tool_id or name
|
|
438
|
+
tool_info = pending_tools.get(key) or {}
|
|
439
|
+
is_committed = tool_info.get("committed", False)
|
|
440
|
+
|
|
441
|
+
# Handle todo_write tool specially - show floating todo panel
|
|
442
|
+
if name == "todo_write" and success:
|
|
443
|
+
tool_data = data.get("data") or {}
|
|
444
|
+
todos = tool_data.get("todos", [])
|
|
445
|
+
if todos and self.verbose:
|
|
446
|
+
# Finalize current tool line first (only if not committed)
|
|
447
|
+
if not is_committed:
|
|
448
|
+
self._finalize_tool_spinner(success)
|
|
449
|
+
pending_tools.pop(key, None)
|
|
450
|
+
self._update_floating_todos(todos)
|
|
451
|
+
return # Don't show tool line for todo_write
|
|
452
|
+
|
|
453
|
+
# Handle file edit tools - show mini diff (this is important to keep)
|
|
454
|
+
if name in ("write_file", "write_to_file", "apply_diff", "edit", "str_replace_editor") and success:
|
|
455
|
+
tool_data = data.get("data") or {} # Handle None explicitly
|
|
456
|
+
# Finalize tool line before showing diff (only if not committed)
|
|
457
|
+
if not is_committed:
|
|
458
|
+
self._finalize_tool_spinner(success)
|
|
459
|
+
self._render_file_change_inline(name, tool_data, data.get("args") or {})
|
|
460
|
+
# Mark as handled
|
|
461
|
+
pending_tools.pop(key, None)
|
|
462
|
+
self._current_tool = None
|
|
463
|
+
return
|
|
464
|
+
|
|
364
465
|
if not self.verbose:
|
|
365
466
|
return
|
|
366
467
|
|
|
@@ -369,38 +470,14 @@ class SSERenderer:
|
|
|
369
470
|
self._render_agent_spawn_result(data)
|
|
370
471
|
return
|
|
371
472
|
|
|
372
|
-
# Sub-agent events: don't print result lines
|
|
473
|
+
# Sub-agent events: don't print result lines
|
|
373
474
|
if subagent_id:
|
|
374
475
|
return
|
|
375
476
|
|
|
376
|
-
#
|
|
377
|
-
pending_tools
|
|
378
|
-
tool_id = data.get("tool_id")
|
|
379
|
-
key = tool_id or name
|
|
380
|
-
tool_info = pending_tools.pop(key, None) or self._current_tool or {}
|
|
381
|
-
args = tool_info.get("args", {})
|
|
382
|
-
|
|
383
|
-
# Calculate duration
|
|
384
|
-
duration = ""
|
|
385
|
-
start_time = tool_info.get("start_time")
|
|
386
|
-
if start_time:
|
|
387
|
-
elapsed = time.time() - start_time
|
|
388
|
-
if elapsed >= 0.5: # Only show if >= 0.5s
|
|
389
|
-
duration = f" {elapsed:.1f}s"
|
|
390
|
-
|
|
391
|
-
# Format args for display
|
|
392
|
-
args_display = self._format_tool_args(name, args)
|
|
393
|
-
|
|
394
|
-
# Build complete line: • ToolName(args)
|
|
395
|
-
if success:
|
|
396
|
-
# Format: • tool(args) result 1.2s
|
|
397
|
-
result_text = f" [dim]{summary}[/dim]" if summary else ""
|
|
398
|
-
duration_text = f" [dim]{duration}[/dim]" if duration else ""
|
|
399
|
-
self.console.print(f" [green]✓[/green] [bold]{name}[/bold]({args_display}){result_text}{duration_text}")
|
|
400
|
-
else:
|
|
401
|
-
error_text = summary or "failed"
|
|
402
|
-
self.console.print(f" [red]✗[/red] [bold]{name}[/bold]({args_display}) [red]{error_text}[/red]")
|
|
477
|
+
# Remove from pending tools (tool_info already fetched earlier)
|
|
478
|
+
pending_tools.pop(key, None)
|
|
403
479
|
|
|
480
|
+
# Track completed tools
|
|
404
481
|
self._completed_tools.append({
|
|
405
482
|
"name": name,
|
|
406
483
|
"success": success,
|
|
@@ -408,23 +485,27 @@ class SSERenderer:
|
|
|
408
485
|
})
|
|
409
486
|
self._current_tool = None
|
|
410
487
|
|
|
488
|
+
# Check if this tool was committed (parallel execution - line already printed)
|
|
489
|
+
# If committed, we can't update the line so skip finalization
|
|
490
|
+
if is_committed:
|
|
491
|
+
return
|
|
492
|
+
|
|
493
|
+
# Finalize the tool line with success/error indicator
|
|
494
|
+
self._finalize_tool_spinner(success)
|
|
495
|
+
|
|
411
496
|
def _render_agent_spawn_result(self, data: dict) -> None:
|
|
412
|
-
"""Render sub-agent spawn result with
|
|
497
|
+
"""Render sub-agent spawn result with zen styling."""
|
|
413
498
|
success = data.get("success", True)
|
|
414
499
|
result_data = data.get("data") or {}
|
|
415
500
|
|
|
416
501
|
# Exit sub-agent mode
|
|
417
502
|
self._in_subagent_mode = False
|
|
418
503
|
|
|
419
|
-
# Clear the progress line and move to new line
|
|
420
|
-
sys.stdout.write(f"\r\033[K")
|
|
421
|
-
sys.stdout.flush()
|
|
422
|
-
|
|
423
504
|
# Calculate duration
|
|
424
505
|
duration = ""
|
|
425
506
|
if self._current_tool and self._current_tool.get("start_time"):
|
|
426
507
|
elapsed = time.time() - self._current_tool["start_time"]
|
|
427
|
-
duration = f" [
|
|
508
|
+
duration = f" [{Colors.DIM}]({elapsed:.1f}s)[/{Colors.DIM}]"
|
|
428
509
|
|
|
429
510
|
if success:
|
|
430
511
|
agent_type = result_data.get("agent_type", "Agent")
|
|
@@ -432,7 +513,7 @@ class SSERenderer:
|
|
|
432
513
|
files_count = len(result_data.get("files_explored", []))
|
|
433
514
|
|
|
434
515
|
self.console.print(
|
|
435
|
-
f" [
|
|
516
|
+
f" [{Colors.SUCCESS}]{STATUS_ACTIVE}[/{Colors.SUCCESS}] {agent_type} completed{duration}"
|
|
436
517
|
)
|
|
437
518
|
# Show stats using our tracked tool count
|
|
438
519
|
stats = []
|
|
@@ -443,10 +524,10 @@ class SSERenderer:
|
|
|
443
524
|
if self._subagent_tool_count > 0:
|
|
444
525
|
stats.append(f"{self._subagent_tool_count} tools")
|
|
445
526
|
if stats:
|
|
446
|
-
self.console.print(f" [
|
|
527
|
+
self.console.print(f" [{Colors.DIM}]{DOT_BULLET} {' · '.join(stats)}[/{Colors.DIM}]")
|
|
447
528
|
else:
|
|
448
529
|
error = result_data.get("error", data.get("summary", "failed"))
|
|
449
|
-
self.console.print(f" [
|
|
530
|
+
self.console.print(f" [{Colors.ERROR}]{STATUS_ERROR}[/{Colors.ERROR}] Agent failed: {error}")
|
|
450
531
|
|
|
451
532
|
self.console.print()
|
|
452
533
|
self._current_tool = None
|
|
@@ -454,7 +535,7 @@ class SSERenderer:
|
|
|
454
535
|
self._subagent_type = None
|
|
455
536
|
|
|
456
537
|
def _render_subagent_start(self, data: dict) -> None:
|
|
457
|
-
"""Render subagent start event
|
|
538
|
+
"""Render subagent start event with animated zen styling."""
|
|
458
539
|
agent_type = data.get("agent_type", "Agent")
|
|
459
540
|
prompt = data.get("prompt", "")
|
|
460
541
|
description = data.get("description", "")
|
|
@@ -463,18 +544,50 @@ class SSERenderer:
|
|
|
463
544
|
self._stop_spinner()
|
|
464
545
|
|
|
465
546
|
# Truncate prompt for display
|
|
466
|
-
prompt_display = prompt[:
|
|
547
|
+
prompt_display = prompt[:120] + "..." if len(prompt) > 120 else prompt
|
|
467
548
|
|
|
468
549
|
self.console.print()
|
|
469
|
-
|
|
550
|
+
|
|
551
|
+
# Animated header line
|
|
552
|
+
header_text = header(f'{agent_type} Agent', 42)
|
|
553
|
+
for i in range(0, len(header_text) + 1, 3):
|
|
554
|
+
sys.stdout.write(f"\r {ANSI.MUTED}{header_text[:i]}{ANSI.RESET}")
|
|
555
|
+
sys.stdout.flush()
|
|
556
|
+
time.sleep(0.004)
|
|
557
|
+
sys.stdout.write("\n")
|
|
558
|
+
sys.stdout.flush()
|
|
559
|
+
|
|
560
|
+
# Agent icon based on type
|
|
470
561
|
if agent_type == "Plan":
|
|
471
|
-
|
|
562
|
+
icon = "◇"
|
|
563
|
+
icon_color = Colors.WARNING
|
|
564
|
+
elif agent_type == "Explore":
|
|
565
|
+
icon = "◈"
|
|
566
|
+
icon_color = Colors.ACCENT
|
|
472
567
|
else:
|
|
473
|
-
|
|
568
|
+
icon = "◆"
|
|
569
|
+
icon_color = Colors.PRIMARY
|
|
570
|
+
|
|
571
|
+
# Animated agent type with icon
|
|
572
|
+
agent_label = f" [{icon_color}]{icon}[/{icon_color}] [{icon_color}]{agent_type}[/{icon_color}]"
|
|
573
|
+
self.console.print(agent_label)
|
|
474
574
|
|
|
475
575
|
if description:
|
|
476
|
-
self.console.print(f" [
|
|
477
|
-
|
|
576
|
+
self.console.print(f" [{Colors.DIM}]{description}[/{Colors.DIM}]")
|
|
577
|
+
|
|
578
|
+
# Animated prompt reveal
|
|
579
|
+
self.console.print()
|
|
580
|
+
sys.stdout.write(f" {ANSI.PRIMARY}{ARROW_RIGHT}{ANSI.RESET} ")
|
|
581
|
+
sys.stdout.flush()
|
|
582
|
+
# Typewriter effect for prompt
|
|
583
|
+
for char in prompt_display:
|
|
584
|
+
sys.stdout.write(char)
|
|
585
|
+
sys.stdout.flush()
|
|
586
|
+
time.sleep(0.008)
|
|
587
|
+
sys.stdout.write("\n")
|
|
588
|
+
sys.stdout.flush()
|
|
589
|
+
|
|
590
|
+
self.console.print()
|
|
478
591
|
|
|
479
592
|
# Enter subagent mode for tool tracking
|
|
480
593
|
self._in_subagent_mode = True
|
|
@@ -483,7 +596,7 @@ class SSERenderer:
|
|
|
483
596
|
self._subagent_start_time = time.time()
|
|
484
597
|
|
|
485
598
|
def _render_subagent_end(self, data: dict) -> None:
|
|
486
|
-
"""Render subagent end event
|
|
599
|
+
"""Render subagent end event with animated zen styling."""
|
|
487
600
|
agent_type = data.get("agent_type", "Agent")
|
|
488
601
|
success = data.get("success", True)
|
|
489
602
|
iterations = data.get("iterations", 0)
|
|
@@ -494,20 +607,40 @@ class SSERenderer:
|
|
|
494
607
|
self._in_subagent_mode = False
|
|
495
608
|
|
|
496
609
|
if success:
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
)
|
|
500
|
-
|
|
610
|
+
# Animated completion spinner then checkmark
|
|
611
|
+
completion_msg = f"{agent_type} completed"
|
|
612
|
+
for i in range(6):
|
|
613
|
+
spinner = SPINNER_FRAMES[i % len(SPINNER_FRAMES)]
|
|
614
|
+
sys.stdout.write(f"\r {ANSI.MUTED}{spinner}{ANSI.RESET} {completion_msg}")
|
|
615
|
+
sys.stdout.flush()
|
|
616
|
+
time.sleep(0.06)
|
|
617
|
+
|
|
618
|
+
# Replace with success indicator
|
|
619
|
+
sys.stdout.write(f"\r {ANSI.SUCCESS}{STATUS_ACTIVE}{ANSI.RESET} [{Colors.SUCCESS}]{completion_msg}[/{Colors.SUCCESS}] [{Colors.DIM}]({execution_time:.1f}s)[/{Colors.DIM}] \n")
|
|
620
|
+
sys.stdout.flush()
|
|
621
|
+
|
|
622
|
+
# Show stats with bullets
|
|
501
623
|
stats = []
|
|
502
624
|
if iterations > 0:
|
|
503
625
|
stats.append(f"{iterations} turns")
|
|
504
626
|
if files_explored > 0:
|
|
505
627
|
stats.append(f"{files_explored} files")
|
|
628
|
+
if self._subagent_tool_count > 0:
|
|
629
|
+
stats.append(f"{self._subagent_tool_count} tools")
|
|
506
630
|
if stats:
|
|
507
|
-
self.console.print(f" [
|
|
631
|
+
self.console.print(f" [{Colors.DIM}]{DOT_BULLET} {' · '.join(stats)}[/{Colors.DIM}]")
|
|
508
632
|
else:
|
|
509
|
-
self.console.print(f" [
|
|
633
|
+
self.console.print(f" [{Colors.ERROR}]{STATUS_ERROR}[/{Colors.ERROR}] [{Colors.ERROR}]{agent_type} failed[/{Colors.ERROR}]")
|
|
510
634
|
|
|
635
|
+
self.console.print()
|
|
636
|
+
# Animated footer
|
|
637
|
+
footer_text = footer(42)
|
|
638
|
+
for i in range(0, len(footer_text) + 1, 4):
|
|
639
|
+
sys.stdout.write(f"\r {ANSI.MUTED}{footer_text[:i]}{ANSI.RESET}")
|
|
640
|
+
sys.stdout.flush()
|
|
641
|
+
time.sleep(0.003)
|
|
642
|
+
sys.stdout.write("\n")
|
|
643
|
+
sys.stdout.flush()
|
|
511
644
|
self.console.print()
|
|
512
645
|
self._subagent_type = None
|
|
513
646
|
self._subagent_tool_count = 0
|
|
@@ -562,35 +695,47 @@ class SSERenderer:
|
|
|
562
695
|
return ""
|
|
563
696
|
|
|
564
697
|
def _render_thinking(self, data: dict) -> None:
|
|
565
|
-
"""Render thinking event.
|
|
698
|
+
"""Render thinking event - show thinking content with muted styling.
|
|
566
699
|
|
|
567
|
-
|
|
700
|
+
Extended thinking from models like Claude is displayed with a subtle
|
|
701
|
+
style to distinguish it from regular output.
|
|
568
702
|
"""
|
|
569
703
|
if not self.verbose:
|
|
570
704
|
return
|
|
571
705
|
|
|
572
|
-
message = data.get("message", "")
|
|
706
|
+
message = data.get("message", data.get("content", ""))
|
|
707
|
+
if not message:
|
|
708
|
+
return
|
|
573
709
|
|
|
574
|
-
#
|
|
575
|
-
|
|
576
|
-
# Extended thinking - show full content
|
|
577
|
-
self._stop_spinner()
|
|
578
|
-
lines = message.strip().split("\n")
|
|
579
|
-
line_count = len(lines)
|
|
580
|
-
char_count = len(message)
|
|
710
|
+
# Store thinking for potential later display
|
|
711
|
+
self._last_thinking = message
|
|
581
712
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
self.console.print(f" [dim]┃[/dim] [dim] {line}[/dim]")
|
|
713
|
+
# Stop any active spinner before printing
|
|
714
|
+
self._stop_spinner()
|
|
585
715
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
716
|
+
# Clear any active tool line
|
|
717
|
+
if self._tool_line_active:
|
|
718
|
+
self._finalize_tool_spinner(True)
|
|
719
|
+
|
|
720
|
+
# Print thinking with muted style and indentation
|
|
721
|
+
# Wrap to terminal width with indent
|
|
722
|
+
indent = " " # 4 spaces for thinking
|
|
723
|
+
max_width = self.console.size.width - len(indent) - 2
|
|
724
|
+
lines = message.strip().split("\n")
|
|
725
|
+
for line in lines:
|
|
726
|
+
# Word wrap long lines
|
|
727
|
+
while len(line) > max_width:
|
|
728
|
+
# Find last space before max_width
|
|
729
|
+
wrap_at = line.rfind(" ", 0, max_width)
|
|
730
|
+
if wrap_at == -1:
|
|
731
|
+
wrap_at = max_width
|
|
732
|
+
self.console.print(f"{indent}[{Colors.MUTED}]{line[:wrap_at]}[/{Colors.MUTED}]")
|
|
733
|
+
line = line[wrap_at:].lstrip()
|
|
734
|
+
if line:
|
|
735
|
+
self.console.print(f"{indent}[{Colors.MUTED}]{line}[/{Colors.MUTED}]")
|
|
591
736
|
|
|
592
737
|
def _render_assistant_text(self, data: dict) -> None:
|
|
593
|
-
"""Render intermediate assistant text (
|
|
738
|
+
"""Render intermediate assistant text - Claude Code style (update spinner)."""
|
|
594
739
|
if not self.verbose:
|
|
595
740
|
return
|
|
596
741
|
|
|
@@ -598,17 +743,18 @@ class SSERenderer:
|
|
|
598
743
|
if not content:
|
|
599
744
|
return
|
|
600
745
|
|
|
601
|
-
#
|
|
602
|
-
|
|
746
|
+
# Claude Code style: show first line as ephemeral spinner message
|
|
747
|
+
first_line = content.split("\n")[0]
|
|
748
|
+
|
|
749
|
+
# Constrain to terminal width
|
|
750
|
+
max_width = min(60, self.console.size.width - 10)
|
|
751
|
+
if len(first_line) > max_width:
|
|
752
|
+
first_line = first_line[:max_width - 3] + "..."
|
|
603
753
|
|
|
604
|
-
|
|
605
|
-
# Truncate long content
|
|
606
|
-
if len(content) > 200:
|
|
607
|
-
content = content[:197] + "..."
|
|
608
|
-
self.console.print(f" [cyan]•[/cyan] [italic]{content}[/italic]")
|
|
754
|
+
self._show_thinking_spinner(first_line)
|
|
609
755
|
|
|
610
756
|
def _render_progress(self, data: dict) -> None:
|
|
611
|
-
"""Render progress event."""
|
|
757
|
+
"""Render progress event with zen styling."""
|
|
612
758
|
if not self.verbose:
|
|
613
759
|
return
|
|
614
760
|
|
|
@@ -616,12 +762,10 @@ class SSERenderer:
|
|
|
616
762
|
percent = data.get("percent")
|
|
617
763
|
|
|
618
764
|
if percent is not None:
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
bar = "█" * filled + "░" * (bar_width - filled)
|
|
622
|
-
self.console.print(f" [dim]┃[/dim] [dim]{bar} {percent:.0f}% {message}[/dim]")
|
|
765
|
+
bar = progress_bar(percent, width=20)
|
|
766
|
+
self.console.print(f" [{Colors.DIM}]{NEST_LINE}[/{Colors.DIM}] [{Colors.MUTED}]{bar} {message}[/{Colors.MUTED}]")
|
|
623
767
|
else:
|
|
624
|
-
self.console.print(f" [
|
|
768
|
+
self.console.print(f" [{Colors.DIM}]{NEST_LINE}[/{Colors.DIM}] [{Colors.MUTED}]{DOT_BULLET} {message}[/{Colors.MUTED}]")
|
|
625
769
|
|
|
626
770
|
def _render_partial(self, data: dict) -> None:
|
|
627
771
|
"""Render partial response (streaming text)."""
|
|
@@ -632,97 +776,128 @@ class SSERenderer:
|
|
|
632
776
|
"""Render final response."""
|
|
633
777
|
content = data.get("content", "")
|
|
634
778
|
|
|
779
|
+
# Clear any ephemeral thinking line before printing response
|
|
780
|
+
with self._spinner_lock:
|
|
781
|
+
sys.stdout.write("\r\033[K")
|
|
782
|
+
sys.stdout.flush()
|
|
783
|
+
|
|
635
784
|
self.console.print()
|
|
636
785
|
self.console.print(Markdown(content))
|
|
637
786
|
|
|
638
787
|
return content
|
|
639
788
|
|
|
640
789
|
def _render_clarification(self, data: dict) -> None:
|
|
641
|
-
"""Render clarification request."""
|
|
790
|
+
"""Render clarification request with zen styling."""
|
|
642
791
|
question = data.get("question", "")
|
|
643
792
|
context = data.get("context", "")
|
|
644
793
|
options = data.get("options", [])
|
|
645
794
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
)
|
|
653
|
-
|
|
654
|
-
if options:
|
|
795
|
+
# Ensure options is a list (not a string)
|
|
796
|
+
if isinstance(options, str):
|
|
797
|
+
options = [options] if options else []
|
|
798
|
+
|
|
799
|
+
# Build content
|
|
800
|
+
content = Text()
|
|
801
|
+
content.append(f"{question}\n", style=Colors.TEXT)
|
|
802
|
+
|
|
803
|
+
if options and isinstance(options, list):
|
|
804
|
+
content.append("\n")
|
|
655
805
|
for i, opt in enumerate(options, 1):
|
|
656
|
-
|
|
657
|
-
|
|
806
|
+
content.append(f"{STATUS_INACTIVE} ", style=Colors.WARNING)
|
|
807
|
+
content.append(f"{i}. ", style=Colors.MUTED)
|
|
808
|
+
content.append(f"{opt}\n", style=Colors.TEXT)
|
|
809
|
+
|
|
810
|
+
# Display in a constrained panel
|
|
811
|
+
self.console.print()
|
|
812
|
+
panel = Panel(
|
|
813
|
+
content,
|
|
814
|
+
title=f"[{Colors.MUTED}]Question[/{Colors.MUTED}]",
|
|
815
|
+
title_align="left",
|
|
816
|
+
border_style=Colors.DIM,
|
|
817
|
+
padding=(0, 2),
|
|
818
|
+
width=min(70, self.console.size.width - 4),
|
|
819
|
+
)
|
|
820
|
+
self.console.print(panel)
|
|
658
821
|
|
|
659
822
|
# Always store clarification (with or without options)
|
|
823
|
+
# Use the corrected options list
|
|
660
824
|
self._pending_clarification = {
|
|
661
825
|
"question": question,
|
|
662
826
|
"context": context,
|
|
663
|
-
"options": options,
|
|
827
|
+
"options": options if isinstance(options, list) else [],
|
|
664
828
|
}
|
|
665
829
|
|
|
666
830
|
def _render_plan_mode_requested(self, data: dict) -> None:
|
|
667
|
-
"""Render plan mode request event
|
|
668
|
-
from rich.panel import Panel
|
|
669
|
-
|
|
831
|
+
"""Render plan mode request event with zen styling."""
|
|
670
832
|
reason = data.get("reason", "")
|
|
671
833
|
|
|
672
834
|
# Store the request data for the CLI to show the menu
|
|
673
835
|
self._plan_mode_requested = data
|
|
674
836
|
|
|
675
|
-
#
|
|
837
|
+
# Build content
|
|
838
|
+
content = Text()
|
|
839
|
+
content.append(f"{STATUS_INFO} ", style=Colors.WARNING)
|
|
840
|
+
content.append("Request to enter plan mode\n", style=Colors.TEXT)
|
|
841
|
+
if reason:
|
|
842
|
+
content.append(f"\n{reason}", style=Colors.DIM)
|
|
843
|
+
|
|
844
|
+
# Display in a constrained panel
|
|
676
845
|
self.console.print()
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
title="[
|
|
680
|
-
|
|
681
|
-
|
|
846
|
+
panel = Panel(
|
|
847
|
+
content,
|
|
848
|
+
title=f"[{Colors.MUTED}]Plan Mode[/{Colors.MUTED}]",
|
|
849
|
+
title_align="left",
|
|
850
|
+
border_style=Colors.DIM,
|
|
851
|
+
padding=(0, 2),
|
|
852
|
+
width=min(60, self.console.size.width - 4),
|
|
853
|
+
)
|
|
854
|
+
self.console.print(panel)
|
|
682
855
|
|
|
683
856
|
def _render_plan_submitted(self, data: dict) -> None:
|
|
684
|
-
"""Render plan submission event
|
|
685
|
-
from rich.panel import Panel
|
|
686
|
-
from rich.markdown import Markdown
|
|
687
|
-
|
|
857
|
+
"""Render plan submission event with zen styling."""
|
|
688
858
|
plan = data.get("plan", "")
|
|
689
859
|
|
|
690
860
|
# Store the plan data for the CLI to show the menu
|
|
691
861
|
self._plan_submitted = data
|
|
692
862
|
|
|
693
|
-
# Render plan
|
|
694
|
-
self.console.print()
|
|
695
|
-
self.console.print(Panel(
|
|
696
|
-
Markdown(plan),
|
|
697
|
-
title="[cyan]📋 Plan[/cyan]",
|
|
698
|
-
border_style="cyan",
|
|
699
|
-
))
|
|
863
|
+
# Render plan in a constrained, professional panel
|
|
700
864
|
self.console.print()
|
|
865
|
+
panel = Panel(
|
|
866
|
+
Markdown(plan, justify="left"),
|
|
867
|
+
title=f"[{Colors.MUTED}]Plan[/{Colors.MUTED}]",
|
|
868
|
+
title_align="left",
|
|
869
|
+
border_style=Colors.DIM,
|
|
870
|
+
padding=(0, 2),
|
|
871
|
+
width=min(80, self.console.size.width - 4),
|
|
872
|
+
)
|
|
873
|
+
self.console.print(panel)
|
|
701
874
|
|
|
702
875
|
def _render_error(self, data: dict) -> None:
|
|
703
|
-
"""Render error event."""
|
|
876
|
+
"""Render error event with zen styling."""
|
|
704
877
|
message = data.get("message", "Unknown error")
|
|
705
878
|
details = data.get("details")
|
|
706
879
|
|
|
707
|
-
self.console.print(
|
|
880
|
+
self.console.print()
|
|
881
|
+
self.console.print(f" [{Colors.ERROR}]{STATUS_ERROR}[/{Colors.ERROR}] [{Colors.ERROR} bold]Error[/{Colors.ERROR} bold] {message}")
|
|
708
882
|
|
|
709
883
|
if details:
|
|
710
|
-
self.console.print(f"[
|
|
884
|
+
self.console.print(f" [{Colors.DIM}]{details}[/{Colors.DIM}]")
|
|
711
885
|
|
|
712
886
|
def _render_warning(self, data: dict) -> None:
|
|
713
|
-
"""Render warning event."""
|
|
887
|
+
"""Render warning event with zen styling."""
|
|
714
888
|
message = data.get("message", "")
|
|
715
|
-
self.console.print(f"[
|
|
889
|
+
self.console.print(f" [{Colors.WARNING}]{STATUS_INFO}[/{Colors.WARNING}] {message}")
|
|
716
890
|
|
|
717
891
|
def _render_session_end(self, data: dict) -> None:
|
|
718
|
-
"""Render session end event."""
|
|
892
|
+
"""Render session end event with zen styling."""
|
|
719
893
|
if not self.verbose:
|
|
720
894
|
return
|
|
721
895
|
|
|
722
896
|
success = data.get("success", True)
|
|
723
897
|
if not success:
|
|
724
898
|
error = data.get("error", "Unknown error")
|
|
725
|
-
self.console.print(
|
|
899
|
+
self.console.print()
|
|
900
|
+
self.console.print(f" [{Colors.ERROR}]{STATUS_ERROR}[/{Colors.ERROR}] Session ended with error: {error}")
|
|
726
901
|
|
|
727
902
|
def _render_context_frame(self, data: dict) -> None:
|
|
728
903
|
"""Store context frame data to render at end of stream."""
|
|
@@ -731,7 +906,9 @@ class SSERenderer:
|
|
|
731
906
|
|
|
732
907
|
def _render_final_context_frame(self) -> None:
|
|
733
908
|
"""Render the final context frame at end of agent loop."""
|
|
734
|
-
|
|
909
|
+
# Only show context frame when EMDASH_INJECT_CONTEXT_FRAME is enabled
|
|
910
|
+
inject_enabled = os.getenv("EMDASH_INJECT_CONTEXT_FRAME", "").lower() in ("1", "true", "yes")
|
|
911
|
+
if not inject_enabled or not self._last_context_frame:
|
|
735
912
|
return
|
|
736
913
|
|
|
737
914
|
data = self._last_context_frame
|
|
@@ -752,7 +929,7 @@ class SSERenderer:
|
|
|
752
929
|
return
|
|
753
930
|
|
|
754
931
|
self.console.print()
|
|
755
|
-
self.console.print("[
|
|
932
|
+
self.console.print(f"[{Colors.MUTED}]{header('Context Frame', 30)}[/{Colors.MUTED}]")
|
|
756
933
|
|
|
757
934
|
# Show total context
|
|
758
935
|
if context_tokens > 0:
|
|
@@ -765,7 +942,7 @@ class SSERenderer:
|
|
|
765
942
|
if tokens > 0:
|
|
766
943
|
breakdown_parts.append(f"{key}: {tokens:,}")
|
|
767
944
|
if breakdown_parts:
|
|
768
|
-
self.console.print(f" [
|
|
945
|
+
self.console.print(f" [{Colors.DIM}]{DOT_BULLET} {' | '.join(breakdown_parts)}[/{Colors.DIM}]")
|
|
769
946
|
|
|
770
947
|
# Show other stats
|
|
771
948
|
stats = []
|
|
@@ -777,18 +954,302 @@ class SSERenderer:
|
|
|
777
954
|
stats.append(f"{item_count} context items")
|
|
778
955
|
|
|
779
956
|
if stats:
|
|
780
|
-
self.console.print(f" [
|
|
957
|
+
self.console.print(f" [{Colors.DIM}]{DOT_BULLET} {' · '.join(stats)}[/{Colors.DIM}]")
|
|
781
958
|
|
|
782
959
|
# Show reranked items (for testing)
|
|
783
960
|
items = reading.get("items", [])
|
|
784
961
|
if items:
|
|
785
|
-
self.console.print(
|
|
962
|
+
self.console.print()
|
|
963
|
+
self.console.print(f" [bold]Reranked Items ({len(items)}):[/bold]")
|
|
786
964
|
for item in items[:10]: # Show top 10
|
|
787
965
|
name = item.get("name", "?")
|
|
788
966
|
item_type = item.get("type", "?")
|
|
789
967
|
score = item.get("score")
|
|
790
968
|
file_path = item.get("file", "")
|
|
791
|
-
score_str = f" [
|
|
792
|
-
self.console.print(f" [
|
|
969
|
+
score_str = f" [{Colors.PRIMARY}]({score:.3f})[/{Colors.PRIMARY}]" if score is not None else ""
|
|
970
|
+
self.console.print(f" [{Colors.DIM}]{item_type}[/{Colors.DIM}] [bold]{name}[/bold]{score_str}")
|
|
793
971
|
if file_path:
|
|
794
|
-
self.console.print(f" [
|
|
972
|
+
self.console.print(f" [{Colors.DIM}]{file_path}[/{Colors.DIM}]")
|
|
973
|
+
|
|
974
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
975
|
+
# Claude Code style spinner methods
|
|
976
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
977
|
+
|
|
978
|
+
def _format_tool_args_short(self, tool_name: str, args: dict) -> str:
|
|
979
|
+
"""Format tool args in short form for spinner display."""
|
|
980
|
+
if not args:
|
|
981
|
+
return ""
|
|
982
|
+
|
|
983
|
+
# Tool-specific short formatting
|
|
984
|
+
if tool_name in ("glob", "grep", "semantic_search"):
|
|
985
|
+
pattern = args.get("pattern", args.get("query", ""))
|
|
986
|
+
if pattern:
|
|
987
|
+
return f'"{pattern[:40]}{"..." if len(pattern) > 40 else ""}"'
|
|
988
|
+
elif tool_name in ("read_file", "write_to_file", "write_file", "list_files", "apply_diff", "edit"):
|
|
989
|
+
path = args.get("path", args.get("file_path", ""))
|
|
990
|
+
if path:
|
|
991
|
+
# Show just filename or last part of path
|
|
992
|
+
if "/" in path:
|
|
993
|
+
path = path.split("/")[-1]
|
|
994
|
+
return path[:50]
|
|
995
|
+
elif tool_name == "bash":
|
|
996
|
+
cmd = args.get("command", "")
|
|
997
|
+
if cmd:
|
|
998
|
+
return f"{cmd[:40]}{'...' if len(cmd) > 40 else ''}"
|
|
999
|
+
|
|
1000
|
+
# Default: show first arg value (short)
|
|
1001
|
+
if args:
|
|
1002
|
+
first_val = str(list(args.values())[0])
|
|
1003
|
+
if len(first_val) > 40:
|
|
1004
|
+
first_val = first_val[:37] + "..."
|
|
1005
|
+
return first_val
|
|
1006
|
+
|
|
1007
|
+
return ""
|
|
1008
|
+
|
|
1009
|
+
def _show_thinking_spinner(self, text: str) -> None:
|
|
1010
|
+
"""Show ephemeral thinking spinner that will be cleared (not finalized).
|
|
1011
|
+
|
|
1012
|
+
Unlike _show_tool_spinner, this doesn't track the line for finalization.
|
|
1013
|
+
The thinking text is cleared when the next event arrives.
|
|
1014
|
+
|
|
1015
|
+
Args:
|
|
1016
|
+
text: The thinking text to show
|
|
1017
|
+
"""
|
|
1018
|
+
# Braille spinner frames
|
|
1019
|
+
spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
1020
|
+
frame = spinner_frames[self._action_count % len(spinner_frames)]
|
|
1021
|
+
|
|
1022
|
+
with self._spinner_lock:
|
|
1023
|
+
# Clear line and show spinner + text (muted style for thinking)
|
|
1024
|
+
sys.stdout.write(f"\r\033[K {ANSI.MUTED}{frame} {text}{ANSI.RESET}")
|
|
1025
|
+
sys.stdout.flush()
|
|
1026
|
+
# Don't set _tool_line_active - this is ephemeral
|
|
1027
|
+
|
|
1028
|
+
def _show_spinner_line(self, text: str) -> None:
|
|
1029
|
+
"""Show a spinner line that replaces itself (for subagents).
|
|
1030
|
+
|
|
1031
|
+
Args:
|
|
1032
|
+
text: The text to show after the spinner
|
|
1033
|
+
"""
|
|
1034
|
+
# Braille spinner frames
|
|
1035
|
+
spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
1036
|
+
frame = spinner_frames[self._action_count % len(spinner_frames)]
|
|
1037
|
+
|
|
1038
|
+
with self._spinner_lock:
|
|
1039
|
+
# Clear line and show spinner + text
|
|
1040
|
+
sys.stdout.write(f"\r\033[K {ANSI.MUTED}{frame}{ANSI.RESET} {text}")
|
|
1041
|
+
sys.stdout.flush()
|
|
1042
|
+
self._tool_line_active = True
|
|
1043
|
+
self._current_tool_line = text
|
|
1044
|
+
|
|
1045
|
+
def _show_tool_spinner(self, name: str, args_summary: str) -> None:
|
|
1046
|
+
"""Show a tool with spinner that will be finalized with result.
|
|
1047
|
+
|
|
1048
|
+
Args:
|
|
1049
|
+
name: Tool name
|
|
1050
|
+
args_summary: Short args summary
|
|
1051
|
+
"""
|
|
1052
|
+
# Braille spinner frames
|
|
1053
|
+
spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
1054
|
+
frame = spinner_frames[self._action_count % len(spinner_frames)]
|
|
1055
|
+
|
|
1056
|
+
with self._spinner_lock:
|
|
1057
|
+
# Two-tone: name in warm sand, args in warm gray
|
|
1058
|
+
if args_summary:
|
|
1059
|
+
line = f" {ANSI.MUTED}{frame}{ANSI.RESET} {ANSI.SECONDARY}{name}{ANSI.RESET}{ANSI.SHADOW}({args_summary}){ANSI.RESET}"
|
|
1060
|
+
else:
|
|
1061
|
+
line = f" {ANSI.MUTED}{frame}{ANSI.RESET} {ANSI.SECONDARY}{name}{ANSI.RESET}"
|
|
1062
|
+
sys.stdout.write(f"\r\033[K{line}")
|
|
1063
|
+
sys.stdout.flush()
|
|
1064
|
+
self._tool_line_active = True
|
|
1065
|
+
self._current_tool_name = name
|
|
1066
|
+
self._current_tool_args = args_summary
|
|
1067
|
+
|
|
1068
|
+
def _commit_tool_line(self) -> None:
|
|
1069
|
+
"""Commit the current tool line (print with spinner) without finalizing.
|
|
1070
|
+
|
|
1071
|
+
This is used for parallel tools - when a new tool starts while another
|
|
1072
|
+
is still running, we commit the previous one to allow stacking.
|
|
1073
|
+
"""
|
|
1074
|
+
if not self._tool_line_active:
|
|
1075
|
+
return
|
|
1076
|
+
|
|
1077
|
+
with self._spinner_lock:
|
|
1078
|
+
# Braille spinner frame to show "in progress"
|
|
1079
|
+
spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
1080
|
+
frame = spinner_frames[self._action_count % len(spinner_frames)]
|
|
1081
|
+
|
|
1082
|
+
if self._current_tool_args:
|
|
1083
|
+
line = f" {ANSI.MUTED}{frame}{ANSI.RESET} {ANSI.SECONDARY}{self._current_tool_name}{ANSI.RESET}{ANSI.SHADOW}({self._current_tool_args}){ANSI.RESET}"
|
|
1084
|
+
else:
|
|
1085
|
+
line = f" {ANSI.MUTED}{frame}{ANSI.RESET} {ANSI.SECONDARY}{self._current_tool_name}{ANSI.RESET}"
|
|
1086
|
+
sys.stdout.write(f"\r\033[K{line}\n")
|
|
1087
|
+
sys.stdout.flush()
|
|
1088
|
+
|
|
1089
|
+
# Mark this tool as committed in pending_tools
|
|
1090
|
+
for key, info in self._pending_tools.items():
|
|
1091
|
+
if info.get("name") == self._current_tool_name:
|
|
1092
|
+
info["committed"] = True
|
|
1093
|
+
break
|
|
1094
|
+
|
|
1095
|
+
self._tool_line_active = False
|
|
1096
|
+
self._current_tool_name = ""
|
|
1097
|
+
self._current_tool_args = ""
|
|
1098
|
+
self._current_tool_line = ""
|
|
1099
|
+
|
|
1100
|
+
def _finalize_tool_spinner(self, success: bool = True) -> None:
|
|
1101
|
+
"""Finalize the current tool spinner line with success/error icon.
|
|
1102
|
+
|
|
1103
|
+
Args:
|
|
1104
|
+
success: Whether the tool succeeded
|
|
1105
|
+
"""
|
|
1106
|
+
if not self._tool_line_active:
|
|
1107
|
+
return
|
|
1108
|
+
|
|
1109
|
+
with self._spinner_lock:
|
|
1110
|
+
# Replace spinner with result icon, keep two-tone style
|
|
1111
|
+
icon = f"{ANSI.SUCCESS}▸{ANSI.RESET}" if success else f"{ANSI.ERROR}▸{ANSI.RESET}"
|
|
1112
|
+
if self._current_tool_args:
|
|
1113
|
+
line = f" {icon} {ANSI.SECONDARY}{self._current_tool_name}{ANSI.RESET}{ANSI.SHADOW}({self._current_tool_args}){ANSI.RESET}"
|
|
1114
|
+
else:
|
|
1115
|
+
line = f" {icon} {ANSI.SECONDARY}{self._current_tool_name}{ANSI.RESET}"
|
|
1116
|
+
sys.stdout.write(f"\r\033[K{line}\n")
|
|
1117
|
+
sys.stdout.flush()
|
|
1118
|
+
self._tool_line_active = False
|
|
1119
|
+
self._current_tool_name = ""
|
|
1120
|
+
self._current_tool_args = ""
|
|
1121
|
+
self._current_tool_line = ""
|
|
1122
|
+
|
|
1123
|
+
def _clear_spinner_line(self) -> None:
|
|
1124
|
+
"""Clear the current spinner line without finalizing."""
|
|
1125
|
+
with self._spinner_lock:
|
|
1126
|
+
sys.stdout.write("\r\033[K")
|
|
1127
|
+
sys.stdout.flush()
|
|
1128
|
+
self._tool_line_active = False
|
|
1129
|
+
|
|
1130
|
+
def _finalize_tool_line(self) -> None:
|
|
1131
|
+
"""Finalize any remaining tool line at end of stream."""
|
|
1132
|
+
# Finalize any active tool spinner
|
|
1133
|
+
if self._tool_line_active:
|
|
1134
|
+
self._finalize_tool_spinner(success=True)
|
|
1135
|
+
|
|
1136
|
+
# Don't show summary - tools are already shown individually
|
|
1137
|
+
|
|
1138
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1139
|
+
# Floating todo panel methods
|
|
1140
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1141
|
+
|
|
1142
|
+
def _show_floating_todos(self, todos: list) -> None:
|
|
1143
|
+
"""Display a floating todo panel at the current position.
|
|
1144
|
+
|
|
1145
|
+
The panel will be redrawn/updated when todos change.
|
|
1146
|
+
|
|
1147
|
+
Args:
|
|
1148
|
+
todos: List of todo items with 'content', 'status', 'activeForm'
|
|
1149
|
+
"""
|
|
1150
|
+
if not todos:
|
|
1151
|
+
return
|
|
1152
|
+
|
|
1153
|
+
self._floating_todos = todos
|
|
1154
|
+
|
|
1155
|
+
# Count statuses
|
|
1156
|
+
completed = sum(1 for t in todos if t.get("status") == "completed")
|
|
1157
|
+
in_progress = sum(1 for t in todos if t.get("status") == "in_progress")
|
|
1158
|
+
pending = sum(1 for t in todos if t.get("status") == "pending")
|
|
1159
|
+
total = len(todos)
|
|
1160
|
+
|
|
1161
|
+
# Build the todo panel
|
|
1162
|
+
lines = []
|
|
1163
|
+
lines.append(f"{ANSI.MUTED}{EM_DASH * 3} Todo List {EM_DASH * 32}{ANSI.RESET}")
|
|
1164
|
+
|
|
1165
|
+
for todo in todos:
|
|
1166
|
+
status = todo.get("status", "pending")
|
|
1167
|
+
content = todo.get("content", "")
|
|
1168
|
+
active_form = todo.get("activeForm", content)
|
|
1169
|
+
|
|
1170
|
+
if status == "completed":
|
|
1171
|
+
lines.append(f" {ANSI.SUCCESS}●{ANSI.RESET} {ANSI.MUTED}\033[9m{content}\033[0m{ANSI.RESET}")
|
|
1172
|
+
elif status == "in_progress":
|
|
1173
|
+
lines.append(f" {ANSI.WARNING}◐{ANSI.RESET} \033[1m{active_form}...\033[0m")
|
|
1174
|
+
else:
|
|
1175
|
+
lines.append(f" {ANSI.MUTED}○{ANSI.RESET} {content}")
|
|
1176
|
+
|
|
1177
|
+
lines.append(f"{ANSI.MUTED}{EM_DASH * 45}{ANSI.RESET}")
|
|
1178
|
+
lines.append(f" {ANSI.MUTED}○ {pending}{ANSI.RESET} {ANSI.WARNING}◐ {in_progress}{ANSI.RESET} {ANSI.SUCCESS}● {completed}{ANSI.RESET} {ANSI.MUTED}total {total}{ANSI.RESET}")
|
|
1179
|
+
lines.append("") # Empty line after
|
|
1180
|
+
|
|
1181
|
+
self._todo_panel_height = len(lines)
|
|
1182
|
+
|
|
1183
|
+
# Print the todo panel
|
|
1184
|
+
sys.stdout.write("\n")
|
|
1185
|
+
for line in lines:
|
|
1186
|
+
sys.stdout.write(line + "\n")
|
|
1187
|
+
sys.stdout.flush()
|
|
1188
|
+
|
|
1189
|
+
def _update_floating_todos(self, todos: list) -> None:
|
|
1190
|
+
"""Update the floating todo panel in place.
|
|
1191
|
+
|
|
1192
|
+
Args:
|
|
1193
|
+
todos: Updated list of todo items
|
|
1194
|
+
"""
|
|
1195
|
+
if not todos:
|
|
1196
|
+
self._clear_floating_todos()
|
|
1197
|
+
return
|
|
1198
|
+
|
|
1199
|
+
if self._floating_todos is None:
|
|
1200
|
+
# First time showing todos
|
|
1201
|
+
self._show_floating_todos(todos)
|
|
1202
|
+
return
|
|
1203
|
+
|
|
1204
|
+
self._floating_todos = todos
|
|
1205
|
+
|
|
1206
|
+
# Move cursor up to overwrite previous panel
|
|
1207
|
+
if self._todo_panel_height > 0:
|
|
1208
|
+
sys.stdout.write(f"\033[{self._todo_panel_height + 1}A") # +1 for the newline before panel
|
|
1209
|
+
|
|
1210
|
+
# Clear those lines
|
|
1211
|
+
for _ in range(self._todo_panel_height + 1):
|
|
1212
|
+
sys.stdout.write("\033[K\n")
|
|
1213
|
+
|
|
1214
|
+
# Move back up
|
|
1215
|
+
sys.stdout.write(f"\033[{self._todo_panel_height + 1}A")
|
|
1216
|
+
|
|
1217
|
+
# Redraw the panel
|
|
1218
|
+
self._show_floating_todos(todos)
|
|
1219
|
+
|
|
1220
|
+
def _clear_floating_todos(self) -> None:
|
|
1221
|
+
"""Clear the floating todo panel."""
|
|
1222
|
+
if self._floating_todos is None:
|
|
1223
|
+
return
|
|
1224
|
+
|
|
1225
|
+
# Just reset the state - the panel stays as part of output
|
|
1226
|
+
self._floating_todos = None
|
|
1227
|
+
self._todo_panel_height = 0
|
|
1228
|
+
|
|
1229
|
+
def _render_file_change_inline(self, tool_name: str, tool_data: dict, args: dict) -> None:
|
|
1230
|
+
"""Render file changes using the shared diff renderer.
|
|
1231
|
+
|
|
1232
|
+
Args:
|
|
1233
|
+
tool_name: Name of the edit tool
|
|
1234
|
+
tool_data: Result data from the tool
|
|
1235
|
+
args: Arguments passed to the tool
|
|
1236
|
+
"""
|
|
1237
|
+
# Extract file path and changes
|
|
1238
|
+
file_path = args.get("path") or args.get("file_path") or tool_data.get("path", "")
|
|
1239
|
+
if not file_path:
|
|
1240
|
+
return
|
|
1241
|
+
|
|
1242
|
+
# Get diff info from tool data
|
|
1243
|
+
old_content = tool_data.get("old_content", "")
|
|
1244
|
+
new_content = tool_data.get("new_content", "")
|
|
1245
|
+
diff_lines = tool_data.get("diff", [])
|
|
1246
|
+
|
|
1247
|
+
# Use shared renderer
|
|
1248
|
+
render_file_change(
|
|
1249
|
+
self.console,
|
|
1250
|
+
file_path,
|
|
1251
|
+
old_content=old_content,
|
|
1252
|
+
new_content=new_content,
|
|
1253
|
+
diff_lines=diff_lines,
|
|
1254
|
+
compact=True,
|
|
1255
|
+
)
|