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.
Files changed (39) hide show
  1. emdash_cli/client.py +12 -28
  2. emdash_cli/commands/__init__.py +2 -2
  3. emdash_cli/commands/agent/constants.py +78 -0
  4. emdash_cli/commands/agent/handlers/__init__.py +10 -0
  5. emdash_cli/commands/agent/handlers/agents.py +67 -39
  6. emdash_cli/commands/agent/handlers/index.py +183 -0
  7. emdash_cli/commands/agent/handlers/misc.py +119 -0
  8. emdash_cli/commands/agent/handlers/registry.py +72 -0
  9. emdash_cli/commands/agent/handlers/rules.py +48 -31
  10. emdash_cli/commands/agent/handlers/sessions.py +1 -1
  11. emdash_cli/commands/agent/handlers/setup.py +187 -54
  12. emdash_cli/commands/agent/handlers/skills.py +42 -4
  13. emdash_cli/commands/agent/handlers/telegram.py +523 -0
  14. emdash_cli/commands/agent/handlers/todos.py +55 -34
  15. emdash_cli/commands/agent/handlers/verify.py +10 -5
  16. emdash_cli/commands/agent/help.py +236 -0
  17. emdash_cli/commands/agent/interactive.py +278 -47
  18. emdash_cli/commands/agent/menus.py +116 -84
  19. emdash_cli/commands/agent/onboarding.py +619 -0
  20. emdash_cli/commands/agent/session_restore.py +210 -0
  21. emdash_cli/commands/index.py +111 -13
  22. emdash_cli/commands/registry.py +635 -0
  23. emdash_cli/commands/skills.py +72 -6
  24. emdash_cli/design.py +328 -0
  25. emdash_cli/diff_renderer.py +438 -0
  26. emdash_cli/integrations/__init__.py +1 -0
  27. emdash_cli/integrations/telegram/__init__.py +15 -0
  28. emdash_cli/integrations/telegram/bot.py +402 -0
  29. emdash_cli/integrations/telegram/bridge.py +980 -0
  30. emdash_cli/integrations/telegram/config.py +155 -0
  31. emdash_cli/integrations/telegram/formatter.py +392 -0
  32. emdash_cli/main.py +52 -2
  33. emdash_cli/sse_renderer.py +632 -171
  34. {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/METADATA +2 -2
  35. emdash_cli-0.1.70.dist-info/RECORD +63 -0
  36. emdash_cli/commands/swarm.py +0 -86
  37. emdash_cli-0.1.46.dist-info/RECORD +0 -49
  38. {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/WHEEL +0 -0
  39. {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/entry_points.txt +0 -0
@@ -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
- # Spinner frames for loading animation
16
- SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
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
- with self._spinner_lock:
172
- sys.stdout.write("\r" + " " * 60 + "\r")
173
- sys.stdout.flush()
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
- self._spinner_idx = (self._spinner_idx + 1) % len(SPINNER_FRAMES)
180
- spinner = SPINNER_FRAMES[self._spinner_idx]
181
- sys.stdout.write(f"\r \033[33m{spinner}\033[0m \033[2m{self._spinner_message}...\033[0m")
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 cyan]{agent}[/bold cyan] [dim]({model})[/dim]")
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: update in place on single line
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._render_subagent_progress(subagent_type or "Agent", name, args)
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
- # Don't print anything here - wait for tool_result to print complete line
307
- # This handles parallel tool execution correctly
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
- def _render_subagent_progress(self, agent_type: str, tool_name: str, args: dict) -> None:
310
- """Render sub-agent progress on a single updating line."""
311
- self._spinner_idx = (self._spinner_idx + 1) % len(SPINNER_FRAMES)
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) > 50:
323
- summary = "..." + path[-47:]
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"])[:50]
391
+ summary = str(args["pattern"])[:60]
392
+ elif "query" in args:
393
+ summary = str(args["query"])[:60]
328
394
 
329
- # Build compact progress line
330
- line = f" │ {spinner} ({agent_type}) {self._subagent_tool_count} tools... {tool_name} {summary}"
331
- # Use ANSI: move to column 0, clear line, print
332
- sys.stdout.write(f"\r\033[K{line}")
333
- sys.stdout.flush()
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 event."""
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, just keep updating progress
473
+ # Sub-agent events: don't print result lines
373
474
  if subagent_id:
374
475
  return
375
476
 
376
- # Get tool info from pending tools (use tool_id if available)
377
- pending_tools = getattr(self, '_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 special UI."""
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" [dim]({elapsed:.1f}s)[/dim]"
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" [green][/green] {agent_type} completed{duration}"
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" [dim]{' · '.join(stats)}[/dim]")
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" [red][/red] Agent failed: {error}")
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 - shows when Explore/Plan agent is spawned."""
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[:150] + "..." if len(prompt) > 150 else prompt
547
+ prompt_display = prompt[:120] + "..." if len(prompt) > 120 else prompt
467
548
 
468
549
  self.console.print()
469
- # Use different colors for different agent types
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
- self.console.print(f" [bold blue]◆ Spawning {agent_type} Agent[/bold blue]")
562
+ icon = "◇"
563
+ icon_color = Colors.WARNING
564
+ elif agent_type == "Explore":
565
+ icon = "◈"
566
+ icon_color = Colors.ACCENT
472
567
  else:
473
- self.console.print(f" [bold magenta]◆ Spawning {agent_type} Agent[/bold magenta]")
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" [dim]{description}[/dim]")
477
- self.console.print(f" [cyan]→[/cyan] {prompt_display}")
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 - shows completion summary."""
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
- self.console.print(
498
- f" [green]✓[/green] {agent_type} completed [dim]({execution_time:.1f}s)[/dim]"
499
- )
500
- # Show stats
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" [dim]{' · '.join(stats)}[/dim]")
631
+ self.console.print(f" [{Colors.DIM}]{DOT_BULLET} {' · '.join(stats)}[/{Colors.DIM}]")
508
632
  else:
509
- self.console.print(f" [red][/red] {agent_type} failed")
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
- Handles both short progress messages and extended thinking content.
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
- # Check if this is extended thinking (long content) vs short progress message
575
- if len(message) > 200:
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
- self.console.print(f" [dim]┃[/dim] [dim italic]💭 Thinking ({char_count:,} chars, {line_count} lines)[/dim italic]")
583
- for line in lines:
584
- self.console.print(f" [dim]┃[/dim] [dim] {line}[/dim]")
713
+ # Stop any active spinner before printing
714
+ self._stop_spinner()
585
715
 
586
- # Store thinking for potential later display
587
- self._last_thinking = message
588
- else:
589
- # Short progress message
590
- self.console.print(f" [dim]┃[/dim] [dim italic]💭 {message}[/dim italic]")
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 (between tool calls)."""
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
- # Stop spinner while showing text
602
- self._stop_spinner()
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
- # Show as bullet point like Claude Code (cyan for assistant reasoning)
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
- bar_width = 20
620
- filled = int(bar_width * percent / 100)
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" [dim][/dim] [dim]{message}[/dim]")
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
- self.console.print()
647
- self.console.print(Panel(
648
- question,
649
- title="[yellow]❓ Question[/yellow]",
650
- border_style="yellow",
651
- padding=(0, 1),
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
- self.console.print(f" [yellow][{i}][/yellow] {opt}")
657
- self.console.print()
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 and store for menu display."""
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
- # Display the request
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
- self.console.print(Panel(
678
- f"[bold]Request to Enter Plan Mode[/bold]\n\n{reason}",
679
- title="[yellow]Plan Mode Request[/yellow]",
680
- border_style="yellow",
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 and store for menu display."""
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 as markdown in a panel
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(f"\n[red bold]✗ Error:[/red bold] {message}")
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"[dim]{details}[/dim]")
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"[yellow]{message}[/yellow]")
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(f"\n[red]Session ended with error: {error}[/red]")
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
- if not self._last_context_frame:
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("[dim]───── Context Frame ─────[/dim]")
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" [dim]Breakdown: {' | '.join(breakdown_parts)}[/dim]")
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" [dim]{' · '.join(stats)}[/dim]")
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(f"\n [bold]Reranked Items ({len(items)}):[/bold]")
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" [cyan]({score:.3f})[/cyan]" if score is not None else ""
792
- self.console.print(f" [dim]{item_type}[/dim] [bold]{name}[/bold]{score_str}")
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" [dim]{file_path}[/dim]")
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
+ )