emdash-cli 0.1.4__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.
@@ -0,0 +1,442 @@
1
+ """SSE event renderer for Rich terminal output."""
2
+
3
+ import json
4
+ import sys
5
+ import time
6
+ from typing import Iterator, Optional
7
+
8
+ from rich.console import Console
9
+ from rich.markdown import Markdown
10
+ from rich.panel import Panel
11
+ from rich.text import Text
12
+
13
+
14
+ # Spinner frames for loading animation
15
+ SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
16
+
17
+
18
+ class SSERenderer:
19
+ """Renders SSE events to Rich terminal output with live updates.
20
+
21
+ Features:
22
+ - Animated spinner while tools execute
23
+ - Special UI for spawning sub-agents
24
+ - Clean, minimal output
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ console: Optional[Console] = None,
30
+ verbose: bool = True,
31
+ ):
32
+ """Initialize the renderer.
33
+
34
+ Args:
35
+ console: Rich console to render to (creates one if not provided)
36
+ verbose: Whether to show tool calls and progress
37
+ """
38
+ self.console = console or Console()
39
+ self.verbose = verbose
40
+ self._partial_response = ""
41
+ self._session_id = None
42
+ self._spec = None
43
+ self._spec_submitted = False
44
+ self._pending_clarification = None
45
+
46
+ # Live display state
47
+ self._current_tool = None
48
+ self._tool_count = 0
49
+ self._completed_tools: list[dict] = []
50
+ self._spinner_idx = 0
51
+ self._waiting_for_next = False
52
+
53
+ # Sub-agent state (for inline updates)
54
+ self._subagent_tool_count = 0
55
+ self._subagent_current_tool = None
56
+
57
+ def render_stream(self, lines: Iterator[str]) -> dict:
58
+ """Render SSE stream to terminal.
59
+
60
+ Args:
61
+ lines: Iterator of SSE lines from HTTP response
62
+
63
+ Returns:
64
+ Dict with session_id, content, spec, and other metadata
65
+ """
66
+ current_event = None
67
+ 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()
90
+
91
+ return {
92
+ "content": final_response,
93
+ "session_id": self._session_id,
94
+ "spec": self._spec,
95
+ "spec_submitted": self._spec_submitted,
96
+ "clarification": self._pending_clarification,
97
+ }
98
+
99
+ 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()
106
+
107
+ 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
113
+
114
+ def _handle_event(self, event_type: str, data: dict) -> Optional[str]:
115
+ """Handle individual SSE event."""
116
+ # Ensure data is a dict
117
+ if not isinstance(data, dict):
118
+ data = {}
119
+
120
+ # Clear waiting indicator when new event arrives
121
+ self._clear_waiting()
122
+
123
+ if event_type == "session_start":
124
+ self._render_session_start(data)
125
+ elif event_type == "tool_start":
126
+ self._render_tool_start(data)
127
+ elif event_type == "tool_result":
128
+ self._render_tool_result(data)
129
+ # Set waiting for next tool/response
130
+ self._waiting_for_next = True
131
+ elif event_type == "thinking":
132
+ self._render_thinking(data)
133
+ elif event_type == "progress":
134
+ self._render_progress(data)
135
+ elif event_type == "partial_response":
136
+ self._render_partial(data)
137
+ elif event_type == "response":
138
+ return self._render_response(data)
139
+ elif event_type == "clarification":
140
+ self._render_clarification(data)
141
+ elif event_type == "error":
142
+ self._render_error(data)
143
+ elif event_type == "warning":
144
+ self._render_warning(data)
145
+ elif event_type == "session_end":
146
+ self._render_session_end(data)
147
+
148
+ return None
149
+
150
+ def _render_session_start(self, data: dict) -> None:
151
+ """Render session start event."""
152
+ if data.get("session_id"):
153
+ self._session_id = data["session_id"]
154
+
155
+ if not self.verbose:
156
+ return
157
+
158
+ agent = data.get("agent_name", "Agent")
159
+ model = data.get("model", "unknown")
160
+
161
+ # Extract model name from full path
162
+ if "/" in model:
163
+ model = model.split("/")[-1]
164
+
165
+ self.console.print()
166
+ self.console.print(f"[bold cyan]{agent}[/bold cyan] [dim]({model})[/dim]")
167
+ self._tool_count = 0
168
+ self._completed_tools = []
169
+
170
+ def _render_tool_start(self, data: dict) -> None:
171
+ """Render tool start event."""
172
+ if not self.verbose:
173
+ return
174
+
175
+ name = data.get("name", "unknown")
176
+ args = data.get("args", {})
177
+ subagent_id = data.get("subagent_id")
178
+ subagent_type = data.get("subagent_type")
179
+
180
+ self._tool_count += 1
181
+ self._current_tool = {"name": name, "args": args, "start_time": time.time()}
182
+
183
+ # Special handling for task tool (spawning sub-agents)
184
+ if name == "task":
185
+ self._render_agent_spawn_start(args)
186
+ return
187
+
188
+ # Sub-agent events: update in place on single line
189
+ if subagent_id:
190
+ self._subagent_tool_count += 1
191
+ self._subagent_current_tool = name
192
+ self._render_subagent_progress(subagent_type or "Agent", name, args)
193
+ return
194
+
195
+ # Format args summary (compact)
196
+ args_summary = self._format_args_summary(args)
197
+
198
+ # Show spinner with tool name
199
+ spinner = SPINNER_FRAMES[0]
200
+ self.console.print(
201
+ f" [dim]┃[/dim] [yellow]{spinner}[/yellow] [bold]{name}[/bold] {args_summary}",
202
+ end="\r"
203
+ )
204
+
205
+ def _render_subagent_progress(self, agent_type: str, tool_name: str, args: dict) -> None:
206
+ """Render sub-agent progress on a single updating line."""
207
+ self._spinner_idx = (self._spinner_idx + 1) % len(SPINNER_FRAMES)
208
+ spinner = SPINNER_FRAMES[self._spinner_idx]
209
+
210
+ # Get a short summary of what's being done
211
+ summary = ""
212
+ if "path" in args:
213
+ path = str(args["path"])
214
+ # Shorten long paths
215
+ if len(path) > 40:
216
+ summary = "..." + path[-37:]
217
+ else:
218
+ summary = path
219
+ elif "pattern" in args:
220
+ summary = str(args["pattern"])[:30]
221
+
222
+ # Clear line and show progress
223
+ line = f" [dim]│[/dim] [yellow]{spinner}[/yellow] [dim cyan]({agent_type})[/dim cyan] {self._subagent_tool_count} tools... [bold]{tool_name}[/bold] [dim]{summary}[/dim]"
224
+ # Pad to clear previous content
225
+ sys.stdout.write(f"\r{' ' * 120}\r")
226
+ self.console.print(line, end="")
227
+
228
+ def _render_agent_spawn_start(self, args: dict) -> None:
229
+ """Render sub-agent spawn start with special UI."""
230
+ agent_type = args.get("subagent_type", "Explore")
231
+ description = args.get("description", "")
232
+ prompt = args.get("prompt", "")
233
+
234
+ # Truncate prompt for display
235
+ prompt_display = prompt[:60] + "..." if len(prompt) > 60 else prompt
236
+
237
+ self.console.print()
238
+ self.console.print(
239
+ f" [bold magenta]◆ Spawning {agent_type} Agent[/bold magenta]"
240
+ )
241
+ if description:
242
+ self.console.print(f" [dim]{description}[/dim]")
243
+ self.console.print(f" [cyan]→[/cyan] {prompt_display}")
244
+
245
+ def _render_tool_result(self, data: dict) -> None:
246
+ """Render tool result event."""
247
+ name = data.get("name", "unknown")
248
+ success = data.get("success", True)
249
+ summary = data.get("summary")
250
+ subagent_id = data.get("subagent_id")
251
+
252
+ # Detect spec submission
253
+ if name == "submit_spec" and success:
254
+ self._spec_submitted = True
255
+ spec_data = data.get("data", {})
256
+ if spec_data:
257
+ self._spec = spec_data.get("content")
258
+
259
+ if not self.verbose:
260
+ return
261
+
262
+ # Special handling for task tool result
263
+ if name == "task":
264
+ self._render_agent_spawn_result(data)
265
+ return
266
+
267
+ # Sub-agent events: don't print result lines, just keep updating progress
268
+ if subagent_id:
269
+ # Progress is already shown by _render_tool_start, nothing to do here
270
+ return
271
+
272
+ # Calculate duration
273
+ duration = ""
274
+ if self._current_tool and self._current_tool.get("start_time"):
275
+ elapsed = time.time() - self._current_tool["start_time"]
276
+ if elapsed >= 0.1:
277
+ duration = f" [dim]{elapsed:.1f}s[/dim]"
278
+
279
+ args_summary = ""
280
+ if self._current_tool:
281
+ args_summary = self._format_args_summary(self._current_tool.get("args", {}))
282
+
283
+ if success:
284
+ status_icon = "[green]✓[/green]"
285
+ result_text = f"[dim]{summary}[/dim]" if summary else ""
286
+ else:
287
+ status_icon = "[red]✗[/red]"
288
+ result_text = f"[red]{summary}[/red]" if summary else "[red]failed[/red]"
289
+
290
+ # Overwrite the spinner line
291
+ self.console.print(
292
+ f" [dim]┃[/dim] {status_icon} [bold]{name}[/bold] {args_summary}{duration} {result_text}"
293
+ )
294
+
295
+ self._completed_tools.append({
296
+ "name": name,
297
+ "success": success,
298
+ "summary": summary,
299
+ })
300
+ self._current_tool = None
301
+
302
+ def _render_agent_spawn_result(self, data: dict) -> None:
303
+ """Render sub-agent spawn result with special UI."""
304
+ success = data.get("success", True)
305
+ result_data = data.get("data") or {}
306
+
307
+ # Calculate duration
308
+ duration = ""
309
+ if self._current_tool and self._current_tool.get("start_time"):
310
+ elapsed = time.time() - self._current_tool["start_time"]
311
+ duration = f" [dim]({elapsed:.1f}s)[/dim]"
312
+
313
+ if success:
314
+ agent_type = result_data.get("agent_type", "Agent")
315
+ iterations = result_data.get("iterations", 0)
316
+ tools_used = result_data.get("tools_used", [])
317
+ files_count = len(result_data.get("files_explored", []))
318
+
319
+ self.console.print(
320
+ f" [green]✓[/green] {agent_type} completed{duration}"
321
+ )
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")
330
+ self.console.print(f" [dim]{' · '.join(stats)}[/dim]")
331
+ else:
332
+ error = result_data.get("error", data.get("summary", "failed"))
333
+ self.console.print(f" [red]✗[/red] Agent failed: {error}")
334
+
335
+ self.console.print()
336
+ self._current_tool = None
337
+
338
+ def _format_args_summary(self, args: dict) -> str:
339
+ """Format args into a compact summary string."""
340
+ if not args:
341
+ return ""
342
+
343
+ parts = []
344
+ for k, v in list(args.items())[:2]:
345
+ v_str = str(v)
346
+ if len(v_str) > 40:
347
+ v_str = v_str[:37] + "..."
348
+ parts.append(f"[dim]{v_str}[/dim]")
349
+
350
+ return " ".join(parts)
351
+
352
+ def _render_thinking(self, data: dict) -> None:
353
+ """Render thinking event."""
354
+ if not self.verbose:
355
+ return
356
+
357
+ message = data.get("message", "")
358
+ self.console.print(f" [dim]┃[/dim] [dim italic]💭 {message}[/dim italic]")
359
+
360
+ def _render_progress(self, data: dict) -> None:
361
+ """Render progress event."""
362
+ if not self.verbose:
363
+ return
364
+
365
+ message = data.get("message", "")
366
+ percent = data.get("percent")
367
+
368
+ if percent is not None:
369
+ bar_width = 20
370
+ filled = int(bar_width * percent / 100)
371
+ bar = "█" * filled + "░" * (bar_width - filled)
372
+ self.console.print(f" [dim]┃[/dim] [dim]{bar} {percent:.0f}% {message}[/dim]")
373
+ else:
374
+ self.console.print(f" [dim]┃[/dim] [dim]{message}[/dim]")
375
+
376
+ def _render_partial(self, data: dict) -> None:
377
+ """Render partial response (streaming text)."""
378
+ content = data.get("content", "")
379
+ self._partial_response += content
380
+
381
+ def _render_response(self, data: dict) -> str:
382
+ """Render final response."""
383
+ content = data.get("content", "")
384
+
385
+ self.console.print()
386
+ self.console.print(Markdown(content))
387
+
388
+ return content
389
+
390
+ def _render_clarification(self, data: dict) -> None:
391
+ """Render clarification request."""
392
+ question = data.get("question", "")
393
+ context = data.get("context", "")
394
+ options = data.get("options", [])
395
+
396
+ self.console.print()
397
+ self.console.print(Panel(
398
+ question,
399
+ title="[yellow]❓ Question[/yellow]",
400
+ border_style="yellow",
401
+ padding=(0, 1),
402
+ ))
403
+
404
+ if options:
405
+ for i, opt in enumerate(options, 1):
406
+ self.console.print(f" [yellow][{i}][/yellow] {opt}")
407
+ self.console.print()
408
+
409
+ self._pending_clarification = {
410
+ "question": question,
411
+ "context": context,
412
+ "options": options,
413
+ }
414
+ else:
415
+ self._pending_clarification = None
416
+
417
+ def _render_error(self, data: dict) -> None:
418
+ """Render error event."""
419
+ message = data.get("message", "Unknown error")
420
+ details = data.get("details")
421
+
422
+ self.console.print(f"\n[red bold]✗ Error:[/red bold] {message}")
423
+
424
+ if details:
425
+ self.console.print(f"[dim]{details}[/dim]")
426
+
427
+ def _render_warning(self, data: dict) -> None:
428
+ """Render warning event."""
429
+ message = data.get("message", "")
430
+ self.console.print(f"[yellow]⚠ {message}[/yellow]")
431
+
432
+ def _render_session_end(self, data: dict) -> None:
433
+ """Render session end event."""
434
+ if not self.verbose:
435
+ return
436
+
437
+ success = data.get("success", True)
438
+ if not success:
439
+ error = data.get("error", "Unknown error")
440
+ 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]")
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: emdash-cli
3
+ Version: 0.1.4
4
+ Summary: EmDash CLI - Command-line interface for code intelligence
5
+ Author: Em Dash Team
6
+ Requires-Python: >=3.10,<4.0
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: click (>=8.1.7,<9.0.0)
14
+ Requires-Dist: emdash-core (>=0.1.4)
15
+ Requires-Dist: httpx (>=0.25.0)
16
+ Requires-Dist: prompt_toolkit (>=3.0.43,<4.0.0)
17
+ Requires-Dist: rich (>=13.7.0)
@@ -0,0 +1,26 @@
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
5
+ emdash_cli/commands/analyze.py,sha256=c9ztbv0Ra7g2AlDmMOy-9L51fDVuoqbuzxnRfomoFIQ,4403
6
+ emdash_cli/commands/auth.py,sha256=SpWdqO1bJCgt4x1B4Pr7hNOucwTuBFJ1oGPOzXtvwZM,3816
7
+ emdash_cli/commands/db.py,sha256=nZK7gLDVE2lAQVYrMx6Swscml5OAtkbg-EcSNSvRIlA,2922
8
+ emdash_cli/commands/embed.py,sha256=kqP5jtYCsZ2_s_I1DjzIUgaod1VUvPiRO0jIIY0HtCs,3244
9
+ emdash_cli/commands/index.py,sha256=njVUEirFPTSsqAR0QRaS_rMKWBe4REBT4hBRWrNnYxI,4607
10
+ emdash_cli/commands/plan.py,sha256=BRiyIhfy_zz2PYy4Qo3a0t77GwHhdssZk6NImOkPi-w,2189
11
+ emdash_cli/commands/projectmd.py,sha256=4y4cn_yFw85jMUm52nGjpqnd-YWvs6ZNEMWJGeJC17Q,1605
12
+ emdash_cli/commands/research.py,sha256=xtI9_9emY7-rGQD5xJALTxtgTFmI4dplYW148dtTaTs,1553
13
+ emdash_cli/commands/rules.py,sha256=n85CCG0WNIBEsUK9STJetPmZxoypQtest5BGPsXl0ac,2712
14
+ emdash_cli/commands/search.py,sha256=DrSv_oN2xF1NaKCBICdyII7eupVRsDQ2ysW-TPSU0X0,1661
15
+ emdash_cli/commands/server.py,sha256=UTmLAVolT0krN9xCtMcCSvmQZ9k1QwpFFmXGg9BulRY,3459
16
+ emdash_cli/commands/spec.py,sha256=qafDmzKyRH035p3xTm_VTUsQLDZblIzIg-dxjEPv6tM,1494
17
+ emdash_cli/commands/swarm.py,sha256=s_cntuorNdtNNTD2Qs1p2IcHghMrBMOQuturPS3y9mM,2661
18
+ emdash_cli/commands/tasks.py,sha256=TdyunjSV5w7jpNFwv0fTL-_No5Fyvdm7Z2nXqxWSJec,1635
19
+ 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/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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.2.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ --=emdash_cli.main:start_coding_agent
3
+ em=emdash_cli.main:start_coding_agent
4
+ emdash=emdash_cli.main:cli
5
+