emdash-cli 0.1.4__py3-none-any.whl → 0.1.17__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
emdash_cli/__init__.py CHANGED
@@ -1,3 +1,8 @@
1
1
  """EmDash CLI - Command-line interface for code intelligence."""
2
2
 
3
- __version__ = "0.1.3"
3
+ from importlib.metadata import version, PackageNotFoundError
4
+
5
+ try:
6
+ __version__ = version("emdash-cli")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0-dev"
emdash_cli/client.py CHANGED
@@ -476,7 +476,7 @@ class EmdashClient:
476
476
  def research_stream(
477
477
  self,
478
478
  goal: str,
479
- max_iterations: int = 10,
479
+ max_iterations: int = 50,
480
480
  budget: int = 50,
481
481
  model: Optional[str] = None,
482
482
  ) -> Iterator[str]:
@@ -459,6 +459,7 @@ def _run_interactive(
459
459
  from prompt_toolkit.history import FileHistory
460
460
  from prompt_toolkit.completion import Completer, Completion
461
461
  from prompt_toolkit.styles import Style
462
+ from prompt_toolkit.key_binding import KeyBindings
462
463
  from pathlib import Path
463
464
 
464
465
  # Current mode
@@ -503,11 +504,29 @@ def _run_interactive(
503
504
  history_file.parent.mkdir(parents=True, exist_ok=True)
504
505
  history = FileHistory(str(history_file))
505
506
 
507
+ # Key bindings: Enter submits, Alt+Enter inserts newline
508
+ # Note: Shift+Enter is indistinguishable from Enter in most terminals
509
+ kb = KeyBindings()
510
+
511
+ @kb.add("enter")
512
+ def submit_on_enter(event):
513
+ """Submit on Enter."""
514
+ event.current_buffer.validate_and_handle()
515
+
516
+ @kb.add("escape", "enter") # Alt+Enter (Escape then Enter)
517
+ @kb.add("c-j") # Ctrl+J as alternative for newline
518
+ def insert_newline_alt(event):
519
+ """Insert a newline character with Alt+Enter or Ctrl+J."""
520
+ event.current_buffer.insert_text("\n")
521
+
506
522
  session = PromptSession(
507
523
  history=history,
508
524
  completer=SlashCommandCompleter(),
509
525
  style=PROMPT_STYLE,
510
526
  complete_while_typing=True,
527
+ multiline=True,
528
+ prompt_continuation="... ",
529
+ key_bindings=kb,
511
530
  )
512
531
 
513
532
  def get_prompt():
@@ -5,7 +5,9 @@ import os
5
5
 
6
6
  import click
7
7
  from rich.console import Console
8
- from rich.status import Status
8
+ from rich.panel import Panel
9
+ from rich.progress import BarColumn, Progress, TaskProgressColumn, TextColumn
10
+ from rich.table import Table
9
11
 
10
12
  from ..client import EmdashClient
11
13
  from ..server_manager import get_server_manager
@@ -68,30 +70,96 @@ def index_start(
68
70
  options["model"] = model
69
71
 
70
72
  try:
71
- # Stream indexing progress with spinner
72
- with Status("[bold cyan]Indexing in progress...[/bold cyan]", console=console) as status:
73
+ # Stream indexing progress with progress bar
74
+ final_stats = {}
75
+
76
+ with Progress(
77
+ TextColumn("[bold cyan]{task.description}[/bold cyan]"),
78
+ BarColumn(bar_width=40, complete_style="cyan", finished_style="green"),
79
+ TaskProgressColumn(),
80
+ console=console,
81
+ transient=True,
82
+ ) as progress:
83
+ task = progress.add_task("Starting...", total=100)
84
+
73
85
  for line in client.index_start_stream(repo_path, changed_only):
74
86
  line = line.strip()
87
+ if line.startswith("event: "):
88
+ continue
75
89
  if line.startswith("data: "):
76
90
  try:
77
91
  data = json.loads(line[6:])
78
92
  step = data.get("step") or data.get("message", "")
79
93
  percent = data.get("percent")
94
+
95
+ # Capture final stats from response event
96
+ if data.get("success") and data.get("stats"):
97
+ final_stats = data.get("stats", {})
98
+
80
99
  if step:
81
- if percent is not None:
82
- status.update(f"[bold cyan]{step}[/bold cyan] ({percent:.0f}%)")
83
- else:
84
- status.update(f"[bold cyan]{step}[/bold cyan]")
100
+ progress.update(task, description=step)
101
+ if percent is not None:
102
+ progress.update(task, completed=percent)
85
103
  except json.JSONDecodeError:
86
104
  pass
87
105
 
88
- console.print("\n[green]Indexing complete![/green]")
106
+ # Complete the progress bar
107
+ progress.update(task, completed=100, description="Complete")
108
+
109
+ # Show completion with sense of accomplishment
110
+ _show_completion(repo_path, final_stats, client)
89
111
 
90
112
  except Exception as e:
91
113
  console.print(f"\n[red]Error:[/red] {e}")
92
114
  raise click.Abort()
93
115
 
94
116
 
117
+ def _show_completion(repo_path: str, stats: dict, client: EmdashClient) -> None:
118
+ """Show a nice completion message with stats."""
119
+ # If we don't have stats from the stream, fetch from status endpoint
120
+ if not stats:
121
+ try:
122
+ status_data = client.index_status(repo_path)
123
+ stats = {
124
+ "files": status_data.get("file_count", 0),
125
+ "functions": status_data.get("function_count", 0),
126
+ "classes": status_data.get("class_count", 0),
127
+ "communities": status_data.get("community_count", 0),
128
+ }
129
+ except Exception:
130
+ stats = {}
131
+
132
+ # Build completion message
133
+ console.print()
134
+
135
+ if stats:
136
+ # Create a summary table
137
+ table = Table(show_header=False, box=None, padding=(0, 2))
138
+ table.add_column("Label", style="dim")
139
+ table.add_column("Value", style="bold")
140
+
141
+ if stats.get("files"):
142
+ table.add_row("Files", str(stats["files"]))
143
+ if stats.get("functions"):
144
+ table.add_row("Functions", str(stats["functions"]))
145
+ if stats.get("classes"):
146
+ table.add_row("Classes", str(stats["classes"]))
147
+ if stats.get("communities"):
148
+ table.add_row("Communities", str(stats["communities"]))
149
+
150
+ panel = Panel(
151
+ table,
152
+ title="[bold green]Indexing Complete[/bold green]",
153
+ border_style="green",
154
+ padding=(1, 2),
155
+ )
156
+ console.print(panel)
157
+ else:
158
+ console.print("[bold green]Indexing complete![/bold green]")
159
+
160
+ console.print()
161
+
162
+
95
163
  @index.command("status")
96
164
  @click.argument("repo_path", required=False)
97
165
  def index_status(repo_path: str | None):
@@ -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
@@ -54,6 +55,12 @@ class SSERenderer:
54
55
  self._subagent_tool_count = 0
55
56
  self._subagent_current_tool = None
56
57
 
58
+ # Spinner animation thread
59
+ self._spinner_thread: Optional[threading.Thread] = None
60
+ self._spinner_running = False
61
+ self._spinner_message = "thinking"
62
+ self._spinner_lock = threading.Lock()
63
+
57
64
  def render_stream(self, lines: Iterator[str]) -> dict:
58
65
  """Render SSE stream to terminal.
59
66
 
@@ -66,27 +73,35 @@ class SSERenderer:
66
73
  current_event = None
67
74
  final_response = ""
68
75
 
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()
76
+ # Start spinner while waiting for first event
77
+ if self.verbose:
78
+ self._start_spinner("thinking")
79
+
80
+ try:
81
+ for line in lines:
82
+ line = line.strip()
83
+
84
+ if line.startswith("event: "):
85
+ current_event = line[7:]
86
+ elif line.startswith("data: "):
87
+ try:
88
+ data = json.loads(line[6:])
89
+ # Ensure data is a dict (could be null/None from JSON)
90
+ if data is None:
91
+ data = {}
92
+ if current_event:
93
+ result = self._handle_event(current_event, data)
94
+ if result:
95
+ final_response = result
96
+ except json.JSONDecodeError:
97
+ pass
98
+ elif line == ": ping":
99
+ # SSE keep-alive - ensure spinner is running
100
+ if self.verbose and not self._spinner_running:
101
+ self._start_spinner("waiting")
102
+ finally:
103
+ # Always stop spinner when stream ends
104
+ self._stop_spinner()
90
105
 
91
106
  return {
92
107
  "content": final_response,
@@ -96,20 +111,50 @@ class SSERenderer:
96
111
  "clarification": self._pending_clarification,
97
112
  }
98
113
 
114
+ def _start_spinner(self, message: str = "thinking") -> None:
115
+ """Start the animated spinner in a background thread."""
116
+ if self._spinner_running:
117
+ return
118
+
119
+ self._spinner_message = message
120
+ self._spinner_running = True
121
+ self._spinner_thread = threading.Thread(target=self._spinner_loop, daemon=True)
122
+ self._spinner_thread.start()
123
+
124
+ def _stop_spinner(self) -> None:
125
+ """Stop the spinner and clear the line."""
126
+ if not self._spinner_running:
127
+ return
128
+
129
+ self._spinner_running = False
130
+ if self._spinner_thread:
131
+ self._spinner_thread.join(timeout=0.2)
132
+ self._spinner_thread = None
133
+
134
+ # Clear the spinner line
135
+ with self._spinner_lock:
136
+ sys.stdout.write("\r" + " " * 60 + "\r")
137
+ sys.stdout.flush()
138
+
139
+ def _spinner_loop(self) -> None:
140
+ """Background thread that animates the spinner."""
141
+ while self._spinner_running:
142
+ with self._spinner_lock:
143
+ self._spinner_idx = (self._spinner_idx + 1) % len(SPINNER_FRAMES)
144
+ spinner = SPINNER_FRAMES[self._spinner_idx]
145
+ sys.stdout.write(f"\r \033[33m{spinner}\033[0m \033[2m{self._spinner_message}...\033[0m")
146
+ sys.stdout.flush()
147
+ time.sleep(0.1)
148
+
99
149
  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()
150
+ """Show waiting animation (starts spinner if not running)."""
151
+ if not self._spinner_running:
152
+ self._start_spinner("waiting")
106
153
 
107
154
  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
155
+ """Clear waiting line (stops spinner)."""
156
+ self._stop_spinner()
157
+ self._waiting_for_next = False
113
158
 
114
159
  def _handle_event(self, event_type: str, data: dict) -> Optional[str]:
115
160
  """Handle individual SSE event."""
@@ -126,8 +171,10 @@ class SSERenderer:
126
171
  self._render_tool_start(data)
127
172
  elif event_type == "tool_result":
128
173
  self._render_tool_result(data)
129
- # Set waiting for next tool/response
174
+ # Start spinner while waiting for next tool/response
130
175
  self._waiting_for_next = True
176
+ if self.verbose:
177
+ self._start_spinner("thinking")
131
178
  elif event_type == "thinking":
132
179
  self._render_thinking(data)
133
180
  elif event_type == "progress":
@@ -144,6 +191,8 @@ class SSERenderer:
144
191
  self._render_warning(data)
145
192
  elif event_type == "session_end":
146
193
  self._render_session_end(data)
194
+ elif event_type == "context_frame":
195
+ self._render_context_frame(data)
147
196
 
148
197
  return None
149
198
 
@@ -231,6 +280,10 @@ class SSERenderer:
231
280
  description = args.get("description", "")
232
281
  prompt = args.get("prompt", "")
233
282
 
283
+ # Reset sub-agent tracking
284
+ self._subagent_tool_count = 0
285
+ self._subagent_current_tool = None
286
+
234
287
  # Truncate prompt for display
235
288
  prompt_display = prompt[:60] + "..." if len(prompt) > 60 else prompt
236
289
 
@@ -304,6 +357,10 @@ class SSERenderer:
304
357
  success = data.get("success", True)
305
358
  result_data = data.get("data") or {}
306
359
 
360
+ # Clear the progress line
361
+ sys.stdout.write(f"\r{' ' * 120}\r")
362
+ sys.stdout.flush()
363
+
307
364
  # Calculate duration
308
365
  duration = ""
309
366
  if self._current_tool and self._current_tool.get("start_time"):
@@ -313,20 +370,20 @@ class SSERenderer:
313
370
  if success:
314
371
  agent_type = result_data.get("agent_type", "Agent")
315
372
  iterations = result_data.get("iterations", 0)
316
- tools_used = result_data.get("tools_used", [])
317
373
  files_count = len(result_data.get("files_explored", []))
318
374
 
319
375
  self.console.print(
320
376
  f" [green]✓[/green] {agent_type} completed{duration}"
321
377
  )
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")
378
+ # Show stats using our tracked tool count
379
+ stats = []
380
+ if iterations > 0:
381
+ stats.append(f"{iterations} turns")
382
+ if files_count > 0:
383
+ stats.append(f"{files_count} files")
384
+ if self._subagent_tool_count > 0:
385
+ stats.append(f"{self._subagent_tool_count} tools")
386
+ if stats:
330
387
  self.console.print(f" [dim]{' · '.join(stats)}[/dim]")
331
388
  else:
332
389
  error = result_data.get("error", data.get("summary", "failed"))
@@ -334,6 +391,7 @@ class SSERenderer:
334
391
 
335
392
  self.console.print()
336
393
  self._current_tool = None
394
+ self._subagent_tool_count = 0
337
395
 
338
396
  def _format_args_summary(self, args: dict) -> str:
339
397
  """Format args into a compact summary string."""
@@ -438,5 +496,40 @@ class SSERenderer:
438
496
  if not success:
439
497
  error = data.get("error", "Unknown error")
440
498
  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]")
499
+
500
+ def _render_context_frame(self, data: dict) -> None:
501
+ """Render context frame update (post-agentic loop summary)."""
502
+ if not self.verbose:
503
+ return
504
+
505
+ adding = data.get("adding") or {}
506
+ reading = data.get("reading") or {}
507
+
508
+ # Get stats from the adding data
509
+ step_count = adding.get("step_count", 0)
510
+ entities_found = adding.get("entities_found", 0)
511
+ context_tokens = adding.get("context_tokens", 0)
512
+
513
+ # Get reading stats
514
+ item_count = reading.get("item_count", 0)
515
+
516
+ # Only show if there's something to report
517
+ if step_count == 0 and item_count == 0:
518
+ return
519
+
520
+ self.console.print()
521
+ self.console.print("[dim]───── Context Frame ─────[/dim]")
522
+
523
+ # Show stats line
524
+ stats = []
525
+ if step_count > 0:
526
+ stats.append(f"{step_count} steps")
527
+ if entities_found > 0:
528
+ stats.append(f"{entities_found} entities")
529
+ if item_count > 0:
530
+ stats.append(f"{item_count} context items")
531
+ if context_tokens > 0:
532
+ stats.append(f"{context_tokens:,} context tokens")
533
+
534
+ if stats:
535
+ 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.17
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.17)
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,12 +1,12 @@
1
- emdash_cli/__init__.py,sha256=q-eC58WAvKd48CbdQVJ5kSGA86_V18JmkE1qXkjEyjw,88
2
- emdash_cli/client.py,sha256=7eWchJugC4xtKs8Ob63_kJMdnjyoN3be_1qP8qMujZE,16275
1
+ emdash_cli/__init__.py,sha256=Rnn2O7B8OCEKlVtNRbWOU2-GN75_KLmhEJgOZzY-KwE,232
2
+ emdash_cli/client.py,sha256=1Ri-BpZPfnZuXRhN8sCLna2SRhhr_AVDC_0CeTQPOhM,16275
3
3
  emdash_cli/commands/__init__.py,sha256=zf0lQ6S1UgoIg6NS7Oehxnb3iGD0Wxai0QU1nobgi9U,671
4
- emdash_cli/commands/agent.py,sha256=HAz9exNc2iXiNu5OBJJRJrTKS1z31lkdDzVcIpfItAg,29618
4
+ emdash_cli/commands/agent.py,sha256=aYN9nLL30ETJKewo42d8U7QY9DGqCMTHCpIPiqJGlLA,30323
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
@@ -19,8 +19,8 @@ emdash_cli/commands/tasks.py,sha256=TdyunjSV5w7jpNFwv0fTL-_No5Fyvdm7Z2nXqxWSJec,
19
19
  emdash_cli/commands/team.py,sha256=K1-IJg6iG-9HMF_3JmpNDlNs1PYbb-ThFHU9KU_jKRo,1430
20
20
  emdash_cli/main.py,sha256=pxUzyh01R-FDETMRf8nrBvP9QW3DVgeTJZ1Vsradw-E,2502
21
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,,
22
+ emdash_cli/sse_renderer.py,sha256=55lDLzYngrWmXstb7PNpQcWi2G4u47qWaYqRJjqMUYo,18587
23
+ emdash_cli-0.1.17.dist-info/METADATA,sha256=u7puOJ_vO_Nc_RU7RSCsQOoXV2rSTCd3zEfwpueg3o0,662
24
+ emdash_cli-0.1.17.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
25
+ emdash_cli-0.1.17.dist-info/entry_points.txt,sha256=31CuYD0k-tM8csFWDunc-JoZTxXaifj3oIXz4V0p6F0,122
26
+ emdash_cli-0.1.17.dist-info/RECORD,,