emdash-cli 0.1.4__py3-none-any.whl → 0.1.25__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.
@@ -3,6 +3,7 @@
3
3
  import json
4
4
  import sys
5
5
  import time
6
+ import threading
6
7
  from typing import Iterator, Optional
7
8
 
8
9
  from rich.console import Console
@@ -41,6 +42,7 @@ class SSERenderer:
41
42
  self._session_id = None
42
43
  self._spec = None
43
44
  self._spec_submitted = False
45
+ self._plan_submitted = None # Plan data when submit_plan tool is called
44
46
  self._pending_clarification = None
45
47
 
46
48
  # Live display state
@@ -54,62 +56,126 @@ class SSERenderer:
54
56
  self._subagent_tool_count = 0
55
57
  self._subagent_current_tool = None
56
58
 
57
- def render_stream(self, lines: Iterator[str]) -> dict:
59
+ # Spinner animation thread
60
+ self._spinner_thread: Optional[threading.Thread] = None
61
+ self._spinner_running = False
62
+ self._spinner_message = "thinking"
63
+ self._spinner_lock = threading.Lock()
64
+
65
+ # Extended thinking storage
66
+ self._last_thinking: Optional[str] = None
67
+
68
+ def render_stream(
69
+ self,
70
+ lines: Iterator[str],
71
+ interrupt_event: Optional[threading.Event] = None,
72
+ ) -> dict:
58
73
  """Render SSE stream to terminal.
59
74
 
60
75
  Args:
61
76
  lines: Iterator of SSE lines from HTTP response
77
+ interrupt_event: Optional event to signal interruption (e.g., ESC pressed)
62
78
 
63
79
  Returns:
64
- Dict with session_id, content, spec, and other metadata
80
+ Dict with session_id, content, spec, interrupted flag, and other metadata
65
81
  """
66
82
  current_event = None
67
83
  final_response = ""
68
-
69
- for line in lines:
70
- line = line.strip()
71
-
72
- if line.startswith("event: "):
73
- current_event = line[7:]
74
- elif line.startswith("data: "):
75
- try:
76
- data = json.loads(line[6:])
77
- # Ensure data is a dict (could be null/None from JSON)
78
- if data is None:
79
- data = {}
80
- if current_event:
81
- result = self._handle_event(current_event, data)
82
- if result:
83
- final_response = result
84
- except json.JSONDecodeError:
85
- pass
86
- elif line == ": ping":
87
- # SSE keep-alive - show loading if waiting
88
- if self._waiting_for_next and self.verbose:
89
- self._show_waiting()
84
+ interrupted = False
85
+ self._last_thinking = None # Reset thinking storage
86
+
87
+ # Start spinner while waiting for first event
88
+ if self.verbose:
89
+ self._start_spinner("thinking")
90
+
91
+ try:
92
+ for line in lines:
93
+ # Check for interrupt signal
94
+ if interrupt_event and interrupt_event.is_set():
95
+ self._stop_spinner()
96
+ self.console.print("\n[yellow]Interrupted[/yellow]")
97
+ interrupted = True
98
+ break
99
+
100
+ line = line.strip()
101
+
102
+ if line.startswith("event: "):
103
+ current_event = line[7:]
104
+ elif line.startswith("data: "):
105
+ try:
106
+ data = json.loads(line[6:])
107
+ # Ensure data is a dict (could be null/None from JSON)
108
+ if data is None:
109
+ data = {}
110
+ if current_event:
111
+ result = self._handle_event(current_event, data)
112
+ if result:
113
+ final_response = result
114
+ except json.JSONDecodeError:
115
+ pass
116
+ elif line == ": ping":
117
+ # SSE keep-alive - ensure spinner is running
118
+ if self.verbose and not self._spinner_running:
119
+ self._start_spinner("waiting")
120
+ finally:
121
+ # Always stop spinner when stream ends
122
+ self._stop_spinner()
90
123
 
91
124
  return {
92
125
  "content": final_response,
93
126
  "session_id": self._session_id,
94
127
  "spec": self._spec,
95
128
  "spec_submitted": self._spec_submitted,
129
+ "plan_submitted": self._plan_submitted,
96
130
  "clarification": self._pending_clarification,
131
+ "interrupted": interrupted,
132
+ "thinking": self._last_thinking,
97
133
  }
98
134
 
135
+ def _start_spinner(self, message: str = "thinking") -> None:
136
+ """Start the animated spinner in a background thread."""
137
+ if self._spinner_running:
138
+ return
139
+
140
+ self._spinner_message = message
141
+ self._spinner_running = True
142
+ self._spinner_thread = threading.Thread(target=self._spinner_loop, daemon=True)
143
+ self._spinner_thread.start()
144
+
145
+ def _stop_spinner(self) -> None:
146
+ """Stop the spinner and clear the line."""
147
+ if not self._spinner_running:
148
+ return
149
+
150
+ self._spinner_running = False
151
+ if self._spinner_thread:
152
+ self._spinner_thread.join(timeout=0.2)
153
+ self._spinner_thread = None
154
+
155
+ # Clear the spinner line
156
+ with self._spinner_lock:
157
+ sys.stdout.write("\r" + " " * 60 + "\r")
158
+ sys.stdout.flush()
159
+
160
+ def _spinner_loop(self) -> None:
161
+ """Background thread that animates the spinner."""
162
+ while self._spinner_running:
163
+ with self._spinner_lock:
164
+ self._spinner_idx = (self._spinner_idx + 1) % len(SPINNER_FRAMES)
165
+ spinner = SPINNER_FRAMES[self._spinner_idx]
166
+ sys.stdout.write(f"\r \033[33m{spinner}\033[0m \033[2m{self._spinner_message}...\033[0m")
167
+ sys.stdout.flush()
168
+ time.sleep(0.1)
169
+
99
170
  def _show_waiting(self) -> None:
100
- """Show waiting animation."""
101
- self._spinner_idx = (self._spinner_idx + 1) % len(SPINNER_FRAMES)
102
- spinner = SPINNER_FRAMES[self._spinner_idx]
103
- # Use carriage return to update in place
104
- sys.stdout.write(f"\r [dim]{spinner}[/dim] waiting...")
105
- sys.stdout.flush()
171
+ """Show waiting animation (starts spinner if not running)."""
172
+ if not self._spinner_running:
173
+ self._start_spinner("waiting")
106
174
 
107
175
  def _clear_waiting(self) -> None:
108
- """Clear waiting line."""
109
- if self._waiting_for_next:
110
- sys.stdout.write("\r" + " " * 40 + "\r")
111
- sys.stdout.flush()
112
- self._waiting_for_next = False
176
+ """Clear waiting line (stops spinner)."""
177
+ self._stop_spinner()
178
+ self._waiting_for_next = False
113
179
 
114
180
  def _handle_event(self, event_type: str, data: dict) -> Optional[str]:
115
181
  """Handle individual SSE event."""
@@ -126,8 +192,10 @@ class SSERenderer:
126
192
  self._render_tool_start(data)
127
193
  elif event_type == "tool_result":
128
194
  self._render_tool_result(data)
129
- # Set waiting for next tool/response
195
+ # Start spinner while waiting for next tool/response
130
196
  self._waiting_for_next = True
197
+ if self.verbose:
198
+ self._start_spinner("thinking")
131
199
  elif event_type == "thinking":
132
200
  self._render_thinking(data)
133
201
  elif event_type == "progress":
@@ -138,12 +206,16 @@ class SSERenderer:
138
206
  return self._render_response(data)
139
207
  elif event_type == "clarification":
140
208
  self._render_clarification(data)
209
+ elif event_type == "plan_submitted":
210
+ self._render_plan_submitted(data)
141
211
  elif event_type == "error":
142
212
  self._render_error(data)
143
213
  elif event_type == "warning":
144
214
  self._render_warning(data)
145
215
  elif event_type == "session_end":
146
216
  self._render_session_end(data)
217
+ elif event_type == "context_frame":
218
+ self._render_context_frame(data)
147
219
 
148
220
  return None
149
221
 
@@ -231,6 +303,10 @@ class SSERenderer:
231
303
  description = args.get("description", "")
232
304
  prompt = args.get("prompt", "")
233
305
 
306
+ # Reset sub-agent tracking
307
+ self._subagent_tool_count = 0
308
+ self._subagent_current_tool = None
309
+
234
310
  # Truncate prompt for display
235
311
  prompt_display = prompt[:60] + "..." if len(prompt) > 60 else prompt
236
312
 
@@ -304,6 +380,10 @@ class SSERenderer:
304
380
  success = data.get("success", True)
305
381
  result_data = data.get("data") or {}
306
382
 
383
+ # Clear the progress line
384
+ sys.stdout.write(f"\r{' ' * 120}\r")
385
+ sys.stdout.flush()
386
+
307
387
  # Calculate duration
308
388
  duration = ""
309
389
  if self._current_tool and self._current_tool.get("start_time"):
@@ -313,20 +393,20 @@ class SSERenderer:
313
393
  if success:
314
394
  agent_type = result_data.get("agent_type", "Agent")
315
395
  iterations = result_data.get("iterations", 0)
316
- tools_used = result_data.get("tools_used", [])
317
396
  files_count = len(result_data.get("files_explored", []))
318
397
 
319
398
  self.console.print(
320
399
  f" [green]✓[/green] {agent_type} completed{duration}"
321
400
  )
322
- if iterations > 0 or files_count > 0:
323
- stats = []
324
- if iterations > 0:
325
- stats.append(f"{iterations} turns")
326
- if files_count > 0:
327
- stats.append(f"{files_count} files")
328
- if tools_used:
329
- stats.append(f"{len(tools_used)} tools")
401
+ # Show stats using our tracked tool count
402
+ stats = []
403
+ if iterations > 0:
404
+ stats.append(f"{iterations} turns")
405
+ if files_count > 0:
406
+ stats.append(f"{files_count} files")
407
+ if self._subagent_tool_count > 0:
408
+ stats.append(f"{self._subagent_tool_count} tools")
409
+ if stats:
330
410
  self.console.print(f" [dim]{' · '.join(stats)}[/dim]")
331
411
  else:
332
412
  error = result_data.get("error", data.get("summary", "failed"))
@@ -334,6 +414,7 @@ class SSERenderer:
334
414
 
335
415
  self.console.print()
336
416
  self._current_tool = None
417
+ self._subagent_tool_count = 0
337
418
 
338
419
  def _format_args_summary(self, args: dict) -> str:
339
420
  """Format args into a compact summary string."""
@@ -350,12 +431,32 @@ class SSERenderer:
350
431
  return " ".join(parts)
351
432
 
352
433
  def _render_thinking(self, data: dict) -> None:
353
- """Render thinking event."""
434
+ """Render thinking event.
435
+
436
+ Handles both short progress messages and extended thinking content.
437
+ """
354
438
  if not self.verbose:
355
439
  return
356
440
 
357
441
  message = data.get("message", "")
358
- self.console.print(f" [dim]┃[/dim] [dim italic]💭 {message}[/dim italic]")
442
+
443
+ # Check if this is extended thinking (long content) vs short progress message
444
+ if len(message) > 200:
445
+ # Extended thinking - show summary with collapsible indicator
446
+ self._stop_spinner()
447
+ lines = message.strip().split("\n")
448
+ preview = lines[0][:80] + "..." if len(lines[0]) > 80 else lines[0]
449
+ line_count = len(lines)
450
+ char_count = len(message)
451
+
452
+ self.console.print(f" [dim]┃[/dim] [dim italic]💭 Thinking ({char_count:,} chars, {line_count} lines)[/dim italic]")
453
+ self.console.print(f" [dim]┃[/dim] [dim] {preview}[/dim]")
454
+
455
+ # Store thinking for potential later display
456
+ self._last_thinking = message
457
+ else:
458
+ # Short progress message
459
+ self.console.print(f" [dim]┃[/dim] [dim italic]💭 {message}[/dim italic]")
359
460
 
360
461
  def _render_progress(self, data: dict) -> None:
361
462
  """Render progress event."""
@@ -414,6 +515,67 @@ class SSERenderer:
414
515
  else:
415
516
  self._pending_clarification = None
416
517
 
518
+ def _render_plan_submitted(self, data: dict) -> None:
519
+ """Render plan submission event and store for menu display."""
520
+ from rich.panel import Panel
521
+ from rich.table import Table
522
+ from rich.text import Text
523
+
524
+ title = data.get("title", "Plan")
525
+ summary = data.get("summary", "")
526
+ files_to_modify = data.get("files_to_modify", [])
527
+ implementation_steps = data.get("implementation_steps", [])
528
+ risks = data.get("risks", [])
529
+ testing_strategy = data.get("testing_strategy", "")
530
+
531
+ # Store the plan data for the CLI to show the menu
532
+ self._plan_submitted = data
533
+
534
+ # Build plan display
535
+ self.console.print()
536
+ self.console.print(Panel(
537
+ f"[bold]{title}[/bold]\n\n{summary}",
538
+ title="[cyan]📋 Plan[/cyan]",
539
+ border_style="cyan",
540
+ ))
541
+
542
+ # Critical Files table (always shown)
543
+ if files_to_modify:
544
+ files_table = Table(title="Critical Files", show_header=True, header_style="bold cyan")
545
+ files_table.add_column("File", style="yellow")
546
+ files_table.add_column("Lines", style="dim")
547
+ files_table.add_column("Changes", style="white")
548
+
549
+ for f in files_to_modify:
550
+ if isinstance(f, dict):
551
+ files_table.add_row(
552
+ f.get("path", ""),
553
+ f.get("lines", ""),
554
+ f.get("changes", "")
555
+ )
556
+ else:
557
+ files_table.add_row(str(f), "", "")
558
+
559
+ self.console.print(files_table)
560
+
561
+ # Implementation Steps (only if provided)
562
+ if implementation_steps:
563
+ self.console.print("\n[bold cyan]Implementation Steps[/bold cyan]")
564
+ for i, step in enumerate(implementation_steps, 1):
565
+ self.console.print(f" [dim]{i}.[/dim] {step}")
566
+
567
+ # Risks (only if provided)
568
+ if risks:
569
+ self.console.print("\n[bold yellow]⚠ Risks[/bold yellow]")
570
+ for risk in risks:
571
+ self.console.print(f" [yellow]•[/yellow] {risk}")
572
+
573
+ # Testing (only if provided)
574
+ if testing_strategy:
575
+ self.console.print(f"\n[bold green]Testing:[/bold green] {testing_strategy}")
576
+
577
+ self.console.print()
578
+
417
579
  def _render_error(self, data: dict) -> None:
418
580
  """Render error event."""
419
581
  message = data.get("message", "Unknown error")
@@ -438,5 +600,59 @@ class SSERenderer:
438
600
  if not success:
439
601
  error = data.get("error", "Unknown error")
440
602
  self.console.print(f"\n[red]Session ended with error: {error}[/red]")
441
- elif self._tool_count > 0:
442
- self.console.print(f"\n[dim]───── {self._tool_count} tools ─────[/dim]")
603
+
604
+ def _render_context_frame(self, data: dict) -> None:
605
+ """Render context frame update (post-agentic loop summary)."""
606
+ adding = data.get("adding") or {}
607
+ reading = data.get("reading") or {}
608
+
609
+ # Get stats from the adding data
610
+ step_count = adding.get("step_count", 0)
611
+ entities_found = adding.get("entities_found", 0)
612
+ context_tokens = adding.get("context_tokens", 0)
613
+ context_breakdown = adding.get("context_breakdown", {})
614
+ largest_messages = adding.get("largest_messages", [])
615
+
616
+ # Get reading stats
617
+ item_count = reading.get("item_count", 0)
618
+
619
+ # Only show if there's something to report
620
+ if step_count == 0 and item_count == 0 and context_tokens == 0:
621
+ return
622
+
623
+ self.console.print()
624
+ self.console.print("[dim]───── Context Frame ─────[/dim]")
625
+
626
+ # Show total context
627
+ if context_tokens > 0:
628
+ self.console.print(f" [bold]Total: {context_tokens:,} tokens[/bold]")
629
+
630
+ # Show breakdown
631
+ if context_breakdown:
632
+ breakdown_parts = []
633
+ for key, tokens in context_breakdown.items():
634
+ if tokens > 0:
635
+ breakdown_parts.append(f"{key}: {tokens:,}")
636
+ if breakdown_parts:
637
+ self.console.print(f" [dim]Breakdown: {' | '.join(breakdown_parts)}[/dim]")
638
+
639
+ # Show largest messages (context hogs)
640
+ if largest_messages:
641
+ self.console.print(f" [yellow]Largest messages:[/yellow]")
642
+ for msg in largest_messages[:5]:
643
+ label = msg.get("label", "unknown")
644
+ tokens = msg.get("tokens", 0)
645
+ preview = msg.get("preview", "")[:50].replace("\n", " ")
646
+ self.console.print(f" [dim]{tokens:,} tokens[/dim] - {label}: {preview}...")
647
+
648
+ # Show other stats
649
+ stats = []
650
+ if step_count > 0:
651
+ stats.append(f"{step_count} steps")
652
+ if entities_found > 0:
653
+ stats.append(f"{entities_found} entities")
654
+ if item_count > 0:
655
+ stats.append(f"{item_count} context items")
656
+
657
+ if stats:
658
+ self.console.print(f" [dim]{' · '.join(stats)}[/dim]")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: emdash-cli
3
- Version: 0.1.4
3
+ Version: 0.1.25
4
4
  Summary: EmDash CLI - Command-line interface for code intelligence
5
5
  Author: Em Dash Team
6
6
  Requires-Python: >=3.10,<4.0
@@ -11,7 +11,7 @@ Classifier: Programming Language :: Python :: 3.12
11
11
  Classifier: Programming Language :: Python :: 3.13
12
12
  Classifier: Programming Language :: Python :: 3.14
13
13
  Requires-Dist: click (>=8.1.7,<9.0.0)
14
- Requires-Dist: emdash-core (>=0.1.4)
14
+ Requires-Dist: emdash-core (>=0.1.25)
15
15
  Requires-Dist: httpx (>=0.25.0)
16
16
  Requires-Dist: prompt_toolkit (>=3.0.43,<4.0.0)
17
17
  Requires-Dist: rich (>=13.7.0)
@@ -1,26 +1,28 @@
1
- emdash_cli/__init__.py,sha256=q-eC58WAvKd48CbdQVJ5kSGA86_V18JmkE1qXkjEyjw,88
2
- emdash_cli/client.py,sha256=7eWchJugC4xtKs8Ob63_kJMdnjyoN3be_1qP8qMujZE,16275
3
- emdash_cli/commands/__init__.py,sha256=zf0lQ6S1UgoIg6NS7Oehxnb3iGD0Wxai0QU1nobgi9U,671
4
- emdash_cli/commands/agent.py,sha256=HAz9exNc2iXiNu5OBJJRJrTKS1z31lkdDzVcIpfItAg,29618
1
+ emdash_cli/__init__.py,sha256=Rnn2O7B8OCEKlVtNRbWOU2-GN75_KLmhEJgOZzY-KwE,232
2
+ emdash_cli/client.py,sha256=sPgX2CEfiHc4TXN9TYqFhc8DVKgPyk20Efm66KQAOhE,16761
3
+ emdash_cli/commands/__init__.py,sha256=D9edXBHm69tueUtE4DggTA1_Yjsl9YZaKjBVDY2D_gQ,712
4
+ emdash_cli/commands/agent.py,sha256=RCgSYW6OBx3UFGVAM_byhyrtBKlsXN2xfJaT3w51uDY,27735
5
5
  emdash_cli/commands/analyze.py,sha256=c9ztbv0Ra7g2AlDmMOy-9L51fDVuoqbuzxnRfomoFIQ,4403
6
6
  emdash_cli/commands/auth.py,sha256=SpWdqO1bJCgt4x1B4Pr7hNOucwTuBFJ1oGPOzXtvwZM,3816
7
7
  emdash_cli/commands/db.py,sha256=nZK7gLDVE2lAQVYrMx6Swscml5OAtkbg-EcSNSvRIlA,2922
8
8
  emdash_cli/commands/embed.py,sha256=kqP5jtYCsZ2_s_I1DjzIUgaod1VUvPiRO0jIIY0HtCs,3244
9
- emdash_cli/commands/index.py,sha256=njVUEirFPTSsqAR0QRaS_rMKWBe4REBT4hBRWrNnYxI,4607
9
+ emdash_cli/commands/index.py,sha256=uFNC5whhU9JdF_59FeM99OPdzKLBTJLkLO6vp9pt944,6959
10
10
  emdash_cli/commands/plan.py,sha256=BRiyIhfy_zz2PYy4Qo3a0t77GwHhdssZk6NImOkPi-w,2189
11
11
  emdash_cli/commands/projectmd.py,sha256=4y4cn_yFw85jMUm52nGjpqnd-YWvs6ZNEMWJGeJC17Q,1605
12
12
  emdash_cli/commands/research.py,sha256=xtI9_9emY7-rGQD5xJALTxtgTFmI4dplYW148dtTaTs,1553
13
13
  emdash_cli/commands/rules.py,sha256=n85CCG0WNIBEsUK9STJetPmZxoypQtest5BGPsXl0ac,2712
14
14
  emdash_cli/commands/search.py,sha256=DrSv_oN2xF1NaKCBICdyII7eupVRsDQ2ysW-TPSU0X0,1661
15
15
  emdash_cli/commands/server.py,sha256=UTmLAVolT0krN9xCtMcCSvmQZ9k1QwpFFmXGg9BulRY,3459
16
+ emdash_cli/commands/skills.py,sha256=8N4279Hr8u2L8AgVjSTRVBLJBcXhN5DN7dn5fME62bs,9989
16
17
  emdash_cli/commands/spec.py,sha256=qafDmzKyRH035p3xTm_VTUsQLDZblIzIg-dxjEPv6tM,1494
17
18
  emdash_cli/commands/swarm.py,sha256=s_cntuorNdtNNTD2Qs1p2IcHghMrBMOQuturPS3y9mM,2661
18
19
  emdash_cli/commands/tasks.py,sha256=TdyunjSV5w7jpNFwv0fTL-_No5Fyvdm7Z2nXqxWSJec,1635
19
20
  emdash_cli/commands/team.py,sha256=K1-IJg6iG-9HMF_3JmpNDlNs1PYbb-ThFHU9KU_jKRo,1430
20
- emdash_cli/main.py,sha256=pxUzyh01R-FDETMRf8nrBvP9QW3DVgeTJZ1Vsradw-E,2502
21
+ emdash_cli/keyboard.py,sha256=haYYAuhYGtdjomzhIFy_3Z3eN3BXfMdb4uRQjwB0tbk,4593
22
+ emdash_cli/main.py,sha256=c-faWp-jzf9a0BbXhVoPvPQfGWSryXpYfswehqZCYPM,2593
21
23
  emdash_cli/server_manager.py,sha256=RrLteSHUmcFV4cyHJAEmgM9qHru2mJS08QNLWno6Y3Y,7051
22
- emdash_cli/sse_renderer.py,sha256=lm09h7a4zjahNvwEKkgxWWQjcB7i-k0v6OS4AWVpUSQ,15384
23
- emdash_cli-0.1.4.dist-info/METADATA,sha256=_9lj3mUt5L3rZqbN4HA7Ctol5k1I0h7rZrCY5sUUmBQ,660
24
- emdash_cli-0.1.4.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
25
- emdash_cli-0.1.4.dist-info/entry_points.txt,sha256=31CuYD0k-tM8csFWDunc-JoZTxXaifj3oIXz4V0p6F0,122
26
- emdash_cli-0.1.4.dist-info/RECORD,,
24
+ emdash_cli/sse_renderer.py,sha256=PEbD53ZohMp9yvii_1ELGwVKb8nnA_n17jICeaURkuY,23738
25
+ emdash_cli-0.1.25.dist-info/METADATA,sha256=RFuFUhHJlRcpfzwztnQXTWheB853OVlyWd1tAIzRKsE,662
26
+ emdash_cli-0.1.25.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
27
+ emdash_cli-0.1.25.dist-info/entry_points.txt,sha256=31CuYD0k-tM8csFWDunc-JoZTxXaifj3oIXz4V0p6F0,122
28
+ emdash_cli-0.1.25.dist-info/RECORD,,