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.
Files changed (50) hide show
  1. emdash_cli/client.py +41 -22
  2. emdash_cli/clipboard.py +30 -61
  3. emdash_cli/commands/__init__.py +2 -2
  4. emdash_cli/commands/agent/__init__.py +14 -0
  5. emdash_cli/commands/agent/cli.py +100 -0
  6. emdash_cli/commands/agent/constants.py +63 -0
  7. emdash_cli/commands/agent/file_utils.py +178 -0
  8. emdash_cli/commands/agent/handlers/__init__.py +51 -0
  9. emdash_cli/commands/agent/handlers/agents.py +449 -0
  10. emdash_cli/commands/agent/handlers/auth.py +69 -0
  11. emdash_cli/commands/agent/handlers/doctor.py +319 -0
  12. emdash_cli/commands/agent/handlers/hooks.py +121 -0
  13. emdash_cli/commands/agent/handlers/index.py +183 -0
  14. emdash_cli/commands/agent/handlers/mcp.py +183 -0
  15. emdash_cli/commands/agent/handlers/misc.py +319 -0
  16. emdash_cli/commands/agent/handlers/registry.py +72 -0
  17. emdash_cli/commands/agent/handlers/rules.py +411 -0
  18. emdash_cli/commands/agent/handlers/sessions.py +168 -0
  19. emdash_cli/commands/agent/handlers/setup.py +715 -0
  20. emdash_cli/commands/agent/handlers/skills.py +478 -0
  21. emdash_cli/commands/agent/handlers/telegram.py +475 -0
  22. emdash_cli/commands/agent/handlers/todos.py +119 -0
  23. emdash_cli/commands/agent/handlers/verify.py +653 -0
  24. emdash_cli/commands/agent/help.py +236 -0
  25. emdash_cli/commands/agent/interactive.py +842 -0
  26. emdash_cli/commands/agent/menus.py +760 -0
  27. emdash_cli/commands/agent/onboarding.py +619 -0
  28. emdash_cli/commands/agent/session_restore.py +210 -0
  29. emdash_cli/commands/agent.py +7 -1321
  30. emdash_cli/commands/index.py +111 -13
  31. emdash_cli/commands/registry.py +635 -0
  32. emdash_cli/commands/server.py +99 -40
  33. emdash_cli/commands/skills.py +72 -6
  34. emdash_cli/design.py +328 -0
  35. emdash_cli/diff_renderer.py +438 -0
  36. emdash_cli/integrations/__init__.py +1 -0
  37. emdash_cli/integrations/telegram/__init__.py +15 -0
  38. emdash_cli/integrations/telegram/bot.py +402 -0
  39. emdash_cli/integrations/telegram/bridge.py +865 -0
  40. emdash_cli/integrations/telegram/config.py +155 -0
  41. emdash_cli/integrations/telegram/formatter.py +385 -0
  42. emdash_cli/main.py +52 -2
  43. emdash_cli/server_manager.py +70 -10
  44. emdash_cli/sse_renderer.py +659 -167
  45. {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/METADATA +2 -4
  46. emdash_cli-0.1.67.dist-info/RECORD +63 -0
  47. emdash_cli/commands/swarm.py +0 -86
  48. emdash_cli-0.1.35.dist-info/RECORD +0 -30
  49. {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/WHEEL +0 -0
  50. {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.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
 
@@ -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
- with self._spinner_lock:
164
- sys.stdout.write("\r" + " " * 60 + "\r")
165
- 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()
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
- self._spinner_idx = (self._spinner_idx + 1) % len(SPINNER_FRAMES)
172
- spinner = SPINNER_FRAMES[self._spinner_idx]
173
- 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} ")
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 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
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: update in place on single line
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._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})")
296
365
  return
297
366
 
298
- # Don't print anything here - wait for tool_result to print complete line
299
- # 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()
300
371
 
301
- def _render_subagent_progress(self, agent_type: str, tool_name: str, args: dict) -> None:
302
- """Render sub-agent progress on a single updating line."""
303
- self._spinner_idx = (self._spinner_idx + 1) % len(SPINNER_FRAMES)
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) > 30:
315
- summary = "..." + path[-27:]
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"])[:25]
391
+ summary = str(args["pattern"])[:60]
392
+ elif "query" in args:
393
+ summary = str(args["query"])[:60]
320
394
 
321
- # Build compact progress line
322
- line = f" │ {spinner} ({agent_type}) {self._subagent_tool_count} tools... {tool_name} {summary}"
323
- # Use ANSI: move to column 0, clear line, print
324
- sys.stdout.write(f"\r\033[K{line}")
325
- 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}")
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 event."""
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, just keep updating progress
473
+ # Sub-agent events: don't print result lines
365
474
  if subagent_id:
366
475
  return
367
476
 
368
- # Get tool info from pending tools (use tool_id if available)
369
- pending_tools = getattr(self, '_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 special UI."""
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" [dim]({elapsed:.1f}s)[/dim]"
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" [green][/green] {agent_type} completed{duration}"
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" [dim]{' · '.join(stats)}[/dim]")
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" [red][/red] Agent failed: {error}")
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 - shows when Explore/Plan agent is spawned."""
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[:80] + "..." if len(prompt) > 80 else prompt
547
+ prompt_display = prompt[:120] + "..." if len(prompt) > 120 else prompt
459
548
 
460
549
  self.console.print()
461
- # 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
462
561
  if agent_type == "Plan":
463
- 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
464
567
  else:
465
- 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)
466
574
 
467
575
  if description:
468
- self.console.print(f" [dim]{description}[/dim]")
469
- 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()
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 - shows completion summary."""
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
- self.console.print(
490
- f" [green]✓[/green] {agent_type} completed [dim]({execution_time:.1f}s)[/dim]"
491
- )
492
- # 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
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" [dim]{' · '.join(stats)}[/dim]")
631
+ self.console.print(f" [{Colors.DIM}]{DOT_BULLET} {' · '.join(stats)}[/{Colors.DIM}]")
500
632
  else:
501
- 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}]")
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
- 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.
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
- # Check if this is extended thinking (long content) vs short progress message
567
- if len(message) > 200:
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
- self.console.print(f" [dim]┃[/dim] [dim italic]💭 Thinking ({char_count:,} chars, {line_count} lines)[/dim italic]")
575
- for line in lines:
576
- self.console.print(f" [dim]┃[/dim] [dim] {line}[/dim]")
713
+ # Stop any active spinner before printing
714
+ self._stop_spinner()
577
715
 
578
- # Store thinking for potential later display
579
- self._last_thinking = message
580
- else:
581
- # Short progress message
582
- 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}]")
583
736
 
584
737
  def _render_assistant_text(self, data: dict) -> None:
585
- """Render intermediate assistant text (between tool calls)."""
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
- # Stop spinner while showing text
594
- 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] + "..."
595
753
 
596
- # Show as bullet point like Claude Code (cyan for assistant reasoning)
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
- bar_width = 20
612
- filled = int(bar_width * percent / 100)
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" [dim][/dim] [dim]{message}[/dim]")
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
- self.console.print()
639
- self.console.print(Panel(
640
- question,
641
- title="[yellow]❓ Question[/yellow]",
642
- border_style="yellow",
643
- padding=(0, 1),
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
- self.console.print(f" [yellow][{i}][/yellow] {opt}")
649
- 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)
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 and store for menu display."""
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
- # 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
668
845
  self.console.print()
669
- self.console.print(Panel(
670
- f"[bold]Request to Enter Plan Mode[/bold]\n\n{reason}",
671
- title="[yellow]Plan Mode Request[/yellow]",
672
- border_style="yellow",
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 and store for menu display."""
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 as markdown in a panel
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(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}")
700
882
 
701
883
  if details:
702
- self.console.print(f"[dim]{details}[/dim]")
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"[yellow]{message}[/yellow]")
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(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}")
718
901
 
719
902
  def _render_context_frame(self, data: dict) -> None:
720
- """Render context frame update (post-agentic loop summary)."""
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("[dim]───── Context Frame ─────[/dim]")
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" [dim]Breakdown: {' | '.join(breakdown_parts)}[/dim]")
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" [dim]{' · '.join(stats)}[/dim]")
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
+ )