emdash-cli 0.1.35__py3-none-any.whl → 0.1.67__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 +41 -22
- emdash_cli/clipboard.py +30 -61
- emdash_cli/commands/__init__.py +2 -2
- emdash_cli/commands/agent/__init__.py +14 -0
- emdash_cli/commands/agent/cli.py +100 -0
- emdash_cli/commands/agent/constants.py +63 -0
- emdash_cli/commands/agent/file_utils.py +178 -0
- emdash_cli/commands/agent/handlers/__init__.py +51 -0
- emdash_cli/commands/agent/handlers/agents.py +449 -0
- emdash_cli/commands/agent/handlers/auth.py +69 -0
- emdash_cli/commands/agent/handlers/doctor.py +319 -0
- emdash_cli/commands/agent/handlers/hooks.py +121 -0
- emdash_cli/commands/agent/handlers/index.py +183 -0
- emdash_cli/commands/agent/handlers/mcp.py +183 -0
- emdash_cli/commands/agent/handlers/misc.py +319 -0
- emdash_cli/commands/agent/handlers/registry.py +72 -0
- emdash_cli/commands/agent/handlers/rules.py +411 -0
- emdash_cli/commands/agent/handlers/sessions.py +168 -0
- emdash_cli/commands/agent/handlers/setup.py +715 -0
- emdash_cli/commands/agent/handlers/skills.py +478 -0
- emdash_cli/commands/agent/handlers/telegram.py +475 -0
- emdash_cli/commands/agent/handlers/todos.py +119 -0
- emdash_cli/commands/agent/handlers/verify.py +653 -0
- emdash_cli/commands/agent/help.py +236 -0
- emdash_cli/commands/agent/interactive.py +842 -0
- emdash_cli/commands/agent/menus.py +760 -0
- emdash_cli/commands/agent/onboarding.py +619 -0
- emdash_cli/commands/agent/session_restore.py +210 -0
- emdash_cli/commands/agent.py +7 -1321
- emdash_cli/commands/index.py +111 -13
- emdash_cli/commands/registry.py +635 -0
- emdash_cli/commands/server.py +99 -40
- 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 +865 -0
- emdash_cli/integrations/telegram/config.py +155 -0
- emdash_cli/integrations/telegram/formatter.py +385 -0
- emdash_cli/main.py +52 -2
- emdash_cli/server_manager.py +70 -10
- emdash_cli/sse_renderer.py +659 -167
- {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/METADATA +2 -4
- emdash_cli-0.1.67.dist-info/RECORD +63 -0
- emdash_cli/commands/swarm.py +0 -86
- emdash_cli-0.1.35.dist-info/RECORD +0 -30
- {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/WHEEL +0 -0
- {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.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
|
|
|
@@ -68,6 +86,22 @@ class SSERenderer:
|
|
|
68
86
|
# Extended thinking storage
|
|
69
87
|
self._last_thinking: Optional[str] = None
|
|
70
88
|
|
|
89
|
+
# Context frame storage (rendered at end of stream)
|
|
90
|
+
self._last_context_frame: Optional[dict] = None
|
|
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
|
+
|
|
71
105
|
def render_stream(
|
|
72
106
|
self,
|
|
73
107
|
lines: Iterator[str],
|
|
@@ -126,6 +160,15 @@ class SSERenderer:
|
|
|
126
160
|
finally:
|
|
127
161
|
# Always stop spinner when stream ends
|
|
128
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()
|
|
167
|
+
|
|
168
|
+
# Render context frame at the end (only once)
|
|
169
|
+
if self.verbose:
|
|
170
|
+
self._render_final_context_frame()
|
|
171
|
+
# Keep _last_context_frame for /context command access
|
|
129
172
|
|
|
130
173
|
return {
|
|
131
174
|
"content": final_response,
|
|
@@ -146,6 +189,14 @@ class SSERenderer:
|
|
|
146
189
|
|
|
147
190
|
self._spinner_message = message
|
|
148
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
|
+
|
|
149
200
|
self._spinner_thread = threading.Thread(target=self._spinner_loop, daemon=True)
|
|
150
201
|
self._spinner_thread.start()
|
|
151
202
|
|
|
@@ -159,18 +210,37 @@ class SSERenderer:
|
|
|
159
210
|
self._spinner_thread.join(timeout=0.2)
|
|
160
211
|
self._spinner_thread = None
|
|
161
212
|
|
|
162
|
-
# Clear the spinner line
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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()
|
|
166
218
|
|
|
167
219
|
def _spinner_loop(self) -> None:
|
|
168
|
-
"""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
|
|
169
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
|
+
|
|
170
239
|
with self._spinner_lock:
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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} ")
|
|
174
244
|
sys.stdout.flush()
|
|
175
245
|
time.sleep(0.1)
|
|
176
246
|
|
|
@@ -245,22 +315,22 @@ class SSERenderer:
|
|
|
245
315
|
return
|
|
246
316
|
|
|
247
317
|
agent = data.get("agent_name", "Agent")
|
|
248
|
-
model = data.get("model", "unknown")
|
|
249
|
-
|
|
250
|
-
# Extract model name from full path
|
|
251
|
-
if "/" in model:
|
|
252
|
-
model = model.split("/")[-1]
|
|
253
318
|
|
|
254
319
|
self.console.print()
|
|
255
|
-
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
|
|
256
323
|
self._tool_count = 0
|
|
257
324
|
self._completed_tools = []
|
|
325
|
+
self._action_count = 0
|
|
326
|
+
self._error_count = 0
|
|
327
|
+
self._start_time = time.time()
|
|
258
328
|
|
|
259
329
|
# Start spinner while waiting for first tool
|
|
260
330
|
self._start_spinner("thinking")
|
|
261
331
|
|
|
262
332
|
def _render_tool_start(self, data: dict) -> None:
|
|
263
|
-
"""Render tool start event."""
|
|
333
|
+
"""Render tool start event - show spinner line for current tool."""
|
|
264
334
|
if not self.verbose:
|
|
265
335
|
return
|
|
266
336
|
|
|
@@ -271,13 +341,11 @@ class SSERenderer:
|
|
|
271
341
|
subagent_type = data.get("subagent_type")
|
|
272
342
|
|
|
273
343
|
self._tool_count += 1
|
|
344
|
+
self._action_count += 1
|
|
274
345
|
|
|
275
346
|
# Store tool info for result rendering (keyed by tool_id for parallel support)
|
|
276
|
-
if not hasattr(self, '_pending_tools'):
|
|
277
|
-
self._pending_tools = {}
|
|
278
|
-
# Use tool_id if available, otherwise fall back to name
|
|
279
347
|
key = tool_id or name
|
|
280
|
-
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}
|
|
281
349
|
self._current_tool = self._pending_tools[key]
|
|
282
350
|
|
|
283
351
|
# Stop spinner when tool starts
|
|
@@ -288,21 +356,25 @@ class SSERenderer:
|
|
|
288
356
|
self._render_agent_spawn_start(args)
|
|
289
357
|
return
|
|
290
358
|
|
|
291
|
-
# Sub-agent events:
|
|
359
|
+
# Sub-agent events: show on single updating line
|
|
292
360
|
if subagent_id:
|
|
293
361
|
self._subagent_tool_count += 1
|
|
294
362
|
self._subagent_current_tool = name
|
|
295
|
-
self.
|
|
363
|
+
args_summary = self._format_tool_args_short(name, args)
|
|
364
|
+
self._show_spinner_line(f"{name}({args_summary})")
|
|
296
365
|
return
|
|
297
366
|
|
|
298
|
-
#
|
|
299
|
-
# 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()
|
|
300
371
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
self.
|
|
304
|
-
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)
|
|
305
375
|
|
|
376
|
+
def _render_subagent_progress(self, agent_type: str, tool_name: str, args: dict) -> None:
|
|
377
|
+
"""Render sub-agent tool call with indentation."""
|
|
306
378
|
# Use stored type if not provided
|
|
307
379
|
agent_type = agent_type or self._subagent_type or "Agent"
|
|
308
380
|
|
|
@@ -311,18 +383,20 @@ class SSERenderer:
|
|
|
311
383
|
if "path" in args:
|
|
312
384
|
path = str(args["path"])
|
|
313
385
|
# Shorten long paths
|
|
314
|
-
if len(path) >
|
|
315
|
-
summary = "..." + path[-
|
|
386
|
+
if len(path) > 60:
|
|
387
|
+
summary = "..." + path[-57:]
|
|
316
388
|
else:
|
|
317
389
|
summary = path
|
|
318
390
|
elif "pattern" in args:
|
|
319
|
-
summary = str(args["pattern"])[:
|
|
391
|
+
summary = str(args["pattern"])[:60]
|
|
392
|
+
elif "query" in args:
|
|
393
|
+
summary = str(args["query"])[:60]
|
|
320
394
|
|
|
321
|
-
#
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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}")
|
|
326
400
|
|
|
327
401
|
def _render_agent_spawn_start(self, args: dict) -> None:
|
|
328
402
|
"""Track sub-agent spawn state (rendering done by subagent_start event)."""
|
|
@@ -340,19 +414,54 @@ class SSERenderer:
|
|
|
340
414
|
# Don't render here - subagent_start event will render the UI
|
|
341
415
|
|
|
342
416
|
def _render_tool_result(self, data: dict) -> None:
|
|
343
|
-
"""Render tool result
|
|
417
|
+
"""Render tool result - finalize the tool line with result."""
|
|
344
418
|
name = data.get("name", "unknown")
|
|
345
419
|
success = data.get("success", True)
|
|
346
420
|
summary = data.get("summary")
|
|
347
421
|
subagent_id = data.get("subagent_id")
|
|
348
422
|
|
|
423
|
+
# Track errors
|
|
424
|
+
if not success:
|
|
425
|
+
self._error_count += 1
|
|
426
|
+
|
|
349
427
|
# Detect spec submission
|
|
350
428
|
if name == "submit_spec" and success:
|
|
351
429
|
self._spec_submitted = True
|
|
352
|
-
spec_data = data.get("data"
|
|
430
|
+
spec_data = data.get("data") or {}
|
|
353
431
|
if spec_data:
|
|
354
432
|
self._spec = spec_data.get("content")
|
|
355
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
|
+
|
|
356
465
|
if not self.verbose:
|
|
357
466
|
return
|
|
358
467
|
|
|
@@ -361,38 +470,14 @@ class SSERenderer:
|
|
|
361
470
|
self._render_agent_spawn_result(data)
|
|
362
471
|
return
|
|
363
472
|
|
|
364
|
-
# Sub-agent events: don't print result lines
|
|
473
|
+
# Sub-agent events: don't print result lines
|
|
365
474
|
if subagent_id:
|
|
366
475
|
return
|
|
367
476
|
|
|
368
|
-
#
|
|
369
|
-
pending_tools
|
|
370
|
-
tool_id = data.get("tool_id")
|
|
371
|
-
key = tool_id or name
|
|
372
|
-
tool_info = pending_tools.pop(key, None) or self._current_tool or {}
|
|
373
|
-
args = tool_info.get("args", {})
|
|
374
|
-
|
|
375
|
-
# Calculate duration
|
|
376
|
-
duration = ""
|
|
377
|
-
start_time = tool_info.get("start_time")
|
|
378
|
-
if start_time:
|
|
379
|
-
elapsed = time.time() - start_time
|
|
380
|
-
if elapsed >= 0.5: # Only show if >= 0.5s
|
|
381
|
-
duration = f" {elapsed:.1f}s"
|
|
382
|
-
|
|
383
|
-
# Format args for display
|
|
384
|
-
args_display = self._format_tool_args(name, args)
|
|
385
|
-
|
|
386
|
-
# Build complete line: • ToolName(args)
|
|
387
|
-
if success:
|
|
388
|
-
# Format: • tool(args) result 1.2s
|
|
389
|
-
result_text = f" [dim]{summary}[/dim]" if summary else ""
|
|
390
|
-
duration_text = f" [dim]{duration}[/dim]" if duration else ""
|
|
391
|
-
self.console.print(f" [green]✓[/green] [bold]{name}[/bold]({args_display}){result_text}{duration_text}")
|
|
392
|
-
else:
|
|
393
|
-
error_text = summary or "failed"
|
|
394
|
-
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)
|
|
395
479
|
|
|
480
|
+
# Track completed tools
|
|
396
481
|
self._completed_tools.append({
|
|
397
482
|
"name": name,
|
|
398
483
|
"success": success,
|
|
@@ -400,23 +485,27 @@ class SSERenderer:
|
|
|
400
485
|
})
|
|
401
486
|
self._current_tool = None
|
|
402
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
|
+
|
|
403
496
|
def _render_agent_spawn_result(self, data: dict) -> None:
|
|
404
|
-
"""Render sub-agent spawn result with
|
|
497
|
+
"""Render sub-agent spawn result with zen styling."""
|
|
405
498
|
success = data.get("success", True)
|
|
406
499
|
result_data = data.get("data") or {}
|
|
407
500
|
|
|
408
501
|
# Exit sub-agent mode
|
|
409
502
|
self._in_subagent_mode = False
|
|
410
503
|
|
|
411
|
-
# Clear the progress line and move to new line
|
|
412
|
-
sys.stdout.write(f"\r\033[K")
|
|
413
|
-
sys.stdout.flush()
|
|
414
|
-
|
|
415
504
|
# Calculate duration
|
|
416
505
|
duration = ""
|
|
417
506
|
if self._current_tool and self._current_tool.get("start_time"):
|
|
418
507
|
elapsed = time.time() - self._current_tool["start_time"]
|
|
419
|
-
duration = f" [
|
|
508
|
+
duration = f" [{Colors.DIM}]({elapsed:.1f}s)[/{Colors.DIM}]"
|
|
420
509
|
|
|
421
510
|
if success:
|
|
422
511
|
agent_type = result_data.get("agent_type", "Agent")
|
|
@@ -424,7 +513,7 @@ class SSERenderer:
|
|
|
424
513
|
files_count = len(result_data.get("files_explored", []))
|
|
425
514
|
|
|
426
515
|
self.console.print(
|
|
427
|
-
f" [
|
|
516
|
+
f" [{Colors.SUCCESS}]{STATUS_ACTIVE}[/{Colors.SUCCESS}] {agent_type} completed{duration}"
|
|
428
517
|
)
|
|
429
518
|
# Show stats using our tracked tool count
|
|
430
519
|
stats = []
|
|
@@ -435,10 +524,10 @@ class SSERenderer:
|
|
|
435
524
|
if self._subagent_tool_count > 0:
|
|
436
525
|
stats.append(f"{self._subagent_tool_count} tools")
|
|
437
526
|
if stats:
|
|
438
|
-
self.console.print(f" [
|
|
527
|
+
self.console.print(f" [{Colors.DIM}]{DOT_BULLET} {' · '.join(stats)}[/{Colors.DIM}]")
|
|
439
528
|
else:
|
|
440
529
|
error = result_data.get("error", data.get("summary", "failed"))
|
|
441
|
-
self.console.print(f" [
|
|
530
|
+
self.console.print(f" [{Colors.ERROR}]{STATUS_ERROR}[/{Colors.ERROR}] Agent failed: {error}")
|
|
442
531
|
|
|
443
532
|
self.console.print()
|
|
444
533
|
self._current_tool = None
|
|
@@ -446,7 +535,7 @@ class SSERenderer:
|
|
|
446
535
|
self._subagent_type = None
|
|
447
536
|
|
|
448
537
|
def _render_subagent_start(self, data: dict) -> None:
|
|
449
|
-
"""Render subagent start event
|
|
538
|
+
"""Render subagent start event with animated zen styling."""
|
|
450
539
|
agent_type = data.get("agent_type", "Agent")
|
|
451
540
|
prompt = data.get("prompt", "")
|
|
452
541
|
description = data.get("description", "")
|
|
@@ -455,18 +544,50 @@ class SSERenderer:
|
|
|
455
544
|
self._stop_spinner()
|
|
456
545
|
|
|
457
546
|
# Truncate prompt for display
|
|
458
|
-
prompt_display = prompt[:
|
|
547
|
+
prompt_display = prompt[:120] + "..." if len(prompt) > 120 else prompt
|
|
459
548
|
|
|
460
549
|
self.console.print()
|
|
461
|
-
|
|
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
|
|
462
561
|
if agent_type == "Plan":
|
|
463
|
-
|
|
562
|
+
icon = "◇"
|
|
563
|
+
icon_color = Colors.WARNING
|
|
564
|
+
elif agent_type == "Explore":
|
|
565
|
+
icon = "◈"
|
|
566
|
+
icon_color = Colors.ACCENT
|
|
464
567
|
else:
|
|
465
|
-
|
|
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)
|
|
466
574
|
|
|
467
575
|
if description:
|
|
468
|
-
self.console.print(f" [
|
|
469
|
-
|
|
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()
|
|
470
591
|
|
|
471
592
|
# Enter subagent mode for tool tracking
|
|
472
593
|
self._in_subagent_mode = True
|
|
@@ -475,7 +596,7 @@ class SSERenderer:
|
|
|
475
596
|
self._subagent_start_time = time.time()
|
|
476
597
|
|
|
477
598
|
def _render_subagent_end(self, data: dict) -> None:
|
|
478
|
-
"""Render subagent end event
|
|
599
|
+
"""Render subagent end event with animated zen styling."""
|
|
479
600
|
agent_type = data.get("agent_type", "Agent")
|
|
480
601
|
success = data.get("success", True)
|
|
481
602
|
iterations = data.get("iterations", 0)
|
|
@@ -486,20 +607,40 @@ class SSERenderer:
|
|
|
486
607
|
self._in_subagent_mode = False
|
|
487
608
|
|
|
488
609
|
if success:
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
)
|
|
492
|
-
|
|
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
|
|
493
623
|
stats = []
|
|
494
624
|
if iterations > 0:
|
|
495
625
|
stats.append(f"{iterations} turns")
|
|
496
626
|
if files_explored > 0:
|
|
497
627
|
stats.append(f"{files_explored} files")
|
|
628
|
+
if self._subagent_tool_count > 0:
|
|
629
|
+
stats.append(f"{self._subagent_tool_count} tools")
|
|
498
630
|
if stats:
|
|
499
|
-
self.console.print(f" [
|
|
631
|
+
self.console.print(f" [{Colors.DIM}]{DOT_BULLET} {' · '.join(stats)}[/{Colors.DIM}]")
|
|
500
632
|
else:
|
|
501
|
-
self.console.print(f" [
|
|
633
|
+
self.console.print(f" [{Colors.ERROR}]{STATUS_ERROR}[/{Colors.ERROR}] [{Colors.ERROR}]{agent_type} failed[/{Colors.ERROR}]")
|
|
502
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()
|
|
503
644
|
self.console.print()
|
|
504
645
|
self._subagent_type = None
|
|
505
646
|
self._subagent_tool_count = 0
|
|
@@ -554,35 +695,47 @@ class SSERenderer:
|
|
|
554
695
|
return ""
|
|
555
696
|
|
|
556
697
|
def _render_thinking(self, data: dict) -> None:
|
|
557
|
-
"""Render thinking event.
|
|
698
|
+
"""Render thinking event - show thinking content with muted styling.
|
|
558
699
|
|
|
559
|
-
|
|
700
|
+
Extended thinking from models like Claude is displayed with a subtle
|
|
701
|
+
style to distinguish it from regular output.
|
|
560
702
|
"""
|
|
561
703
|
if not self.verbose:
|
|
562
704
|
return
|
|
563
705
|
|
|
564
|
-
message = data.get("message", "")
|
|
706
|
+
message = data.get("message", data.get("content", ""))
|
|
707
|
+
if not message:
|
|
708
|
+
return
|
|
565
709
|
|
|
566
|
-
#
|
|
567
|
-
|
|
568
|
-
# Extended thinking - show full content
|
|
569
|
-
self._stop_spinner()
|
|
570
|
-
lines = message.strip().split("\n")
|
|
571
|
-
line_count = len(lines)
|
|
572
|
-
char_count = len(message)
|
|
710
|
+
# Store thinking for potential later display
|
|
711
|
+
self._last_thinking = message
|
|
573
712
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
self.console.print(f" [dim]┃[/dim] [dim] {line}[/dim]")
|
|
713
|
+
# Stop any active spinner before printing
|
|
714
|
+
self._stop_spinner()
|
|
577
715
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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}]")
|
|
583
736
|
|
|
584
737
|
def _render_assistant_text(self, data: dict) -> None:
|
|
585
|
-
"""Render intermediate assistant text (
|
|
738
|
+
"""Render intermediate assistant text - Claude Code style (update spinner)."""
|
|
586
739
|
if not self.verbose:
|
|
587
740
|
return
|
|
588
741
|
|
|
@@ -590,17 +743,18 @@ class SSERenderer:
|
|
|
590
743
|
if not content:
|
|
591
744
|
return
|
|
592
745
|
|
|
593
|
-
#
|
|
594
|
-
|
|
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] + "..."
|
|
595
753
|
|
|
596
|
-
|
|
597
|
-
# Truncate long content
|
|
598
|
-
if len(content) > 200:
|
|
599
|
-
content = content[:197] + "..."
|
|
600
|
-
self.console.print(f" [cyan]•[/cyan] [italic]{content}[/italic]")
|
|
754
|
+
self._show_thinking_spinner(first_line)
|
|
601
755
|
|
|
602
756
|
def _render_progress(self, data: dict) -> None:
|
|
603
|
-
"""Render progress event."""
|
|
757
|
+
"""Render progress event with zen styling."""
|
|
604
758
|
if not self.verbose:
|
|
605
759
|
return
|
|
606
760
|
|
|
@@ -608,12 +762,10 @@ class SSERenderer:
|
|
|
608
762
|
percent = data.get("percent")
|
|
609
763
|
|
|
610
764
|
if percent is not None:
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
bar = "█" * filled + "░" * (bar_width - filled)
|
|
614
|
-
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}]")
|
|
615
767
|
else:
|
|
616
|
-
self.console.print(f" [
|
|
768
|
+
self.console.print(f" [{Colors.DIM}]{NEST_LINE}[/{Colors.DIM}] [{Colors.MUTED}]{DOT_BULLET} {message}[/{Colors.MUTED}]")
|
|
617
769
|
|
|
618
770
|
def _render_partial(self, data: dict) -> None:
|
|
619
771
|
"""Render partial response (streaming text)."""
|
|
@@ -624,100 +776,142 @@ class SSERenderer:
|
|
|
624
776
|
"""Render final response."""
|
|
625
777
|
content = data.get("content", "")
|
|
626
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
|
+
|
|
627
784
|
self.console.print()
|
|
628
785
|
self.console.print(Markdown(content))
|
|
629
786
|
|
|
630
787
|
return content
|
|
631
788
|
|
|
632
789
|
def _render_clarification(self, data: dict) -> None:
|
|
633
|
-
"""Render clarification request."""
|
|
790
|
+
"""Render clarification request with zen styling."""
|
|
634
791
|
question = data.get("question", "")
|
|
635
792
|
context = data.get("context", "")
|
|
636
793
|
options = data.get("options", [])
|
|
637
794
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
)
|
|
645
|
-
|
|
646
|
-
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")
|
|
647
805
|
for i, opt in enumerate(options, 1):
|
|
648
|
-
|
|
649
|
-
|
|
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)
|
|
650
821
|
|
|
651
822
|
# Always store clarification (with or without options)
|
|
823
|
+
# Use the corrected options list
|
|
652
824
|
self._pending_clarification = {
|
|
653
825
|
"question": question,
|
|
654
826
|
"context": context,
|
|
655
|
-
"options": options,
|
|
827
|
+
"options": options if isinstance(options, list) else [],
|
|
656
828
|
}
|
|
657
829
|
|
|
658
830
|
def _render_plan_mode_requested(self, data: dict) -> None:
|
|
659
|
-
"""Render plan mode request event
|
|
660
|
-
from rich.panel import Panel
|
|
661
|
-
|
|
831
|
+
"""Render plan mode request event with zen styling."""
|
|
662
832
|
reason = data.get("reason", "")
|
|
663
833
|
|
|
664
834
|
# Store the request data for the CLI to show the menu
|
|
665
835
|
self._plan_mode_requested = data
|
|
666
836
|
|
|
667
|
-
#
|
|
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
|
|
668
845
|
self.console.print()
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
title="[
|
|
672
|
-
|
|
673
|
-
|
|
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)
|
|
674
855
|
|
|
675
856
|
def _render_plan_submitted(self, data: dict) -> None:
|
|
676
|
-
"""Render plan submission event
|
|
677
|
-
from rich.panel import Panel
|
|
678
|
-
from rich.markdown import Markdown
|
|
679
|
-
|
|
857
|
+
"""Render plan submission event with zen styling."""
|
|
680
858
|
plan = data.get("plan", "")
|
|
681
859
|
|
|
682
860
|
# Store the plan data for the CLI to show the menu
|
|
683
861
|
self._plan_submitted = data
|
|
684
862
|
|
|
685
|
-
# Render plan
|
|
686
|
-
self.console.print()
|
|
687
|
-
self.console.print(Panel(
|
|
688
|
-
Markdown(plan),
|
|
689
|
-
title="[cyan]📋 Plan[/cyan]",
|
|
690
|
-
border_style="cyan",
|
|
691
|
-
))
|
|
863
|
+
# Render plan in a constrained, professional panel
|
|
692
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)
|
|
693
874
|
|
|
694
875
|
def _render_error(self, data: dict) -> None:
|
|
695
|
-
"""Render error event."""
|
|
876
|
+
"""Render error event with zen styling."""
|
|
696
877
|
message = data.get("message", "Unknown error")
|
|
697
878
|
details = data.get("details")
|
|
698
879
|
|
|
699
|
-
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}")
|
|
700
882
|
|
|
701
883
|
if details:
|
|
702
|
-
self.console.print(f"[
|
|
884
|
+
self.console.print(f" [{Colors.DIM}]{details}[/{Colors.DIM}]")
|
|
703
885
|
|
|
704
886
|
def _render_warning(self, data: dict) -> None:
|
|
705
|
-
"""Render warning event."""
|
|
887
|
+
"""Render warning event with zen styling."""
|
|
706
888
|
message = data.get("message", "")
|
|
707
|
-
self.console.print(f"[
|
|
889
|
+
self.console.print(f" [{Colors.WARNING}]{STATUS_INFO}[/{Colors.WARNING}] {message}")
|
|
708
890
|
|
|
709
891
|
def _render_session_end(self, data: dict) -> None:
|
|
710
|
-
"""Render session end event."""
|
|
892
|
+
"""Render session end event with zen styling."""
|
|
711
893
|
if not self.verbose:
|
|
712
894
|
return
|
|
713
895
|
|
|
714
896
|
success = data.get("success", True)
|
|
715
897
|
if not success:
|
|
716
898
|
error = data.get("error", "Unknown error")
|
|
717
|
-
self.console.print(
|
|
899
|
+
self.console.print()
|
|
900
|
+
self.console.print(f" [{Colors.ERROR}]{STATUS_ERROR}[/{Colors.ERROR}] Session ended with error: {error}")
|
|
718
901
|
|
|
719
902
|
def _render_context_frame(self, data: dict) -> None:
|
|
720
|
-
"""
|
|
903
|
+
"""Store context frame data to render at end of stream."""
|
|
904
|
+
# Just store the latest context frame, will render at end
|
|
905
|
+
self._last_context_frame = data
|
|
906
|
+
|
|
907
|
+
def _render_final_context_frame(self) -> None:
|
|
908
|
+
"""Render the final context frame at end of agent loop."""
|
|
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:
|
|
912
|
+
return
|
|
913
|
+
|
|
914
|
+
data = self._last_context_frame
|
|
721
915
|
adding = data.get("adding") or {}
|
|
722
916
|
reading = data.get("reading") or {}
|
|
723
917
|
|
|
@@ -735,7 +929,7 @@ class SSERenderer:
|
|
|
735
929
|
return
|
|
736
930
|
|
|
737
931
|
self.console.print()
|
|
738
|
-
self.console.print("[
|
|
932
|
+
self.console.print(f"[{Colors.MUTED}]{header('Context Frame', 30)}[/{Colors.MUTED}]")
|
|
739
933
|
|
|
740
934
|
# Show total context
|
|
741
935
|
if context_tokens > 0:
|
|
@@ -748,7 +942,7 @@ class SSERenderer:
|
|
|
748
942
|
if tokens > 0:
|
|
749
943
|
breakdown_parts.append(f"{key}: {tokens:,}")
|
|
750
944
|
if breakdown_parts:
|
|
751
|
-
self.console.print(f" [
|
|
945
|
+
self.console.print(f" [{Colors.DIM}]{DOT_BULLET} {' | '.join(breakdown_parts)}[/{Colors.DIM}]")
|
|
752
946
|
|
|
753
947
|
# Show other stats
|
|
754
948
|
stats = []
|
|
@@ -760,4 +954,302 @@ class SSERenderer:
|
|
|
760
954
|
stats.append(f"{item_count} context items")
|
|
761
955
|
|
|
762
956
|
if stats:
|
|
763
|
-
self.console.print(f" [
|
|
957
|
+
self.console.print(f" [{Colors.DIM}]{DOT_BULLET} {' · '.join(stats)}[/{Colors.DIM}]")
|
|
958
|
+
|
|
959
|
+
# Show reranked items (for testing)
|
|
960
|
+
items = reading.get("items", [])
|
|
961
|
+
if items:
|
|
962
|
+
self.console.print()
|
|
963
|
+
self.console.print(f" [bold]Reranked Items ({len(items)}):[/bold]")
|
|
964
|
+
for item in items[:10]: # Show top 10
|
|
965
|
+
name = item.get("name", "?")
|
|
966
|
+
item_type = item.get("type", "?")
|
|
967
|
+
score = item.get("score")
|
|
968
|
+
file_path = item.get("file", "")
|
|
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}")
|
|
971
|
+
if file_path:
|
|
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
|
+
)
|