emdash-cli 0.1.17__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.
@@ -1,5 +1,8 @@
1
1
  """Agent CLI commands."""
2
2
 
3
+ import os
4
+ import threading
5
+
3
6
  import click
4
7
  from enum import Enum
5
8
  from rich.console import Console
@@ -7,6 +10,7 @@ from rich.panel import Panel
7
10
  from rich.markdown import Markdown
8
11
 
9
12
  from ..client import EmdashClient
13
+ from ..keyboard import KeyListener
10
14
  from ..server_manager import get_server_manager
11
15
  from ..sse_renderer import SSERenderer
12
16
 
@@ -16,15 +20,13 @@ console = Console()
16
20
  class AgentMode(Enum):
17
21
  """Agent operation modes."""
18
22
  PLAN = "plan"
19
- TASKS = "tasks"
20
23
  CODE = "code"
21
24
 
22
25
 
23
26
  # Slash commands available in interactive mode
24
27
  SLASH_COMMANDS = {
25
28
  # Mode switching
26
- "/plan": "Switch to plan mode (explore codebase, create specs)",
27
- "/tasks": "Switch to tasks mode (generate task lists)",
29
+ "/plan": "Switch to plan mode (explore codebase, create plans)",
28
30
  "/code": "Switch to code mode (execute file changes)",
29
31
  "/mode": "Show current mode",
30
32
  # Generation commands
@@ -51,10 +53,10 @@ def agent():
51
53
  @agent.command("code")
52
54
  @click.argument("task", required=False)
53
55
  @click.option("--model", "-m", default=None, help="Model to use")
54
- @click.option("--mode", type=click.Choice(["plan", "tasks", "code"]), default="code",
56
+ @click.option("--mode", type=click.Choice(["plan", "code"]), default="code",
55
57
  help="Starting mode")
56
58
  @click.option("--quiet", "-q", is_flag=True, help="Less verbose output")
57
- @click.option("--max-iterations", default=20, help="Max agent iterations")
59
+ @click.option("--max-iterations", default=int(os.getenv("EMDASH_MAX_ITERATIONS", "100")), help="Max agent iterations")
58
60
  @click.option("--no-graph-tools", is_flag=True, help="Skip graph exploration tools")
59
61
  @click.option("--save", is_flag=True, help="Save specs to specs/<feature>/")
60
62
  def agent_code(
@@ -72,13 +74,11 @@ def agent_code(
72
74
  Without TASK: Start interactive REPL mode
73
75
 
74
76
  MODES:
75
- plan - Explore codebase and create specifications
76
- tasks - Generate implementation task lists
77
+ plan - Explore codebase and create plans (read-only)
77
78
  code - Execute code changes (default)
78
79
 
79
80
  SLASH COMMANDS (in interactive mode):
80
81
  /plan - Switch to plan mode
81
- /tasks - Switch to tasks mode
82
82
  /code - Switch to code mode
83
83
  /help - Show available commands
84
84
  /reset - Reset session
@@ -111,7 +111,7 @@ def agent_code(
111
111
 
112
112
 
113
113
  def _get_clarification_response(clarification: dict) -> str | None:
114
- """Get user response for clarification with options.
114
+ """Get user response for clarification with interactive selection.
115
115
 
116
116
  Args:
117
117
  clarification: Dict with question, context, and options
@@ -119,49 +119,22 @@ def _get_clarification_response(clarification: dict) -> str | None:
119
119
  Returns:
120
120
  User's selected option or typed response, or None if cancelled
121
121
  """
122
- from prompt_toolkit import PromptSession
123
-
124
- options = clarification.get("options", [])
125
-
126
- session = PromptSession()
127
- console.print("[dim]Enter number or type response:[/dim]")
128
-
129
- try:
130
- response = session.prompt("choice > ").strip()
131
-
132
- if not response:
133
- return None
134
-
135
- # Map number to option
136
- if response.isdigit():
137
- idx = int(response) - 1
138
- if 0 <= idx < len(options):
139
- return options[idx]
140
-
141
- return response
142
- except (KeyboardInterrupt, EOFError):
143
- return None
144
-
145
-
146
- def _show_spec_approval_menu() -> tuple[str, str]:
147
- """Show spec approval menu with arrow-key selection.
148
-
149
- Returns:
150
- Tuple of (choice, feedback) where feedback is only set for 'refine'
151
- """
152
- from prompt_toolkit import Application
122
+ from prompt_toolkit import Application, PromptSession
153
123
  from prompt_toolkit.key_binding import KeyBindings
154
124
  from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
155
125
  from prompt_toolkit.styles import Style
156
126
 
157
- options = [
158
- ("tasks", "Generate implementation tasks"),
159
- ("code", "Start coding directly"),
160
- ("refine", "Provide feedback to improve"),
161
- ("abort", "Cancel and discard"),
162
- ]
127
+ options = clarification.get("options", [])
163
128
 
164
- selected_index = [0] # Use list to allow mutation in closure
129
+ if not options:
130
+ # No options, just get free-form input
131
+ session = PromptSession()
132
+ try:
133
+ return session.prompt("response > ").strip() or None
134
+ except (KeyboardInterrupt, EOFError):
135
+ return None
136
+
137
+ selected_index = [0]
165
138
  result = [None]
166
139
 
167
140
  # Key bindings
@@ -179,64 +152,53 @@ def _show_spec_approval_menu() -> tuple[str, str]:
179
152
 
180
153
  @kb.add("enter")
181
154
  def select(event):
182
- result[0] = options[selected_index[0]][0]
183
- event.app.exit()
184
-
185
- @kb.add("1")
186
- def select_1(event):
187
- result[0] = "tasks"
188
- event.app.exit()
189
-
190
- @kb.add("2")
191
- def select_2(event):
192
- result[0] = "code"
155
+ result[0] = options[selected_index[0]]
193
156
  event.app.exit()
194
157
 
195
- @kb.add("3")
196
- def select_3(event):
197
- result[0] = "refine"
198
- event.app.exit()
199
-
200
- @kb.add("4")
201
- def select_4(event):
202
- result[0] = "abort"
203
- event.app.exit()
158
+ # Number key shortcuts (1-9)
159
+ for i in range(min(9, len(options))):
160
+ @kb.add(str(i + 1))
161
+ def select_by_number(event, idx=i):
162
+ result[0] = options[idx]
163
+ event.app.exit()
204
164
 
205
165
  @kb.add("c-c")
206
- @kb.add("q")
207
166
  @kb.add("escape")
208
167
  def cancel(event):
209
- result[0] = "abort"
168
+ result[0] = None
169
+ event.app.exit()
170
+
171
+ @kb.add("o") # 'o' for Other - custom input
172
+ def other_input(event):
173
+ result[0] = "OTHER_INPUT"
210
174
  event.app.exit()
211
175
 
212
176
  def get_formatted_options():
213
- lines = [("class:title", "What would you like to do with this spec?\n\n")]
214
- for i, (key, desc) in enumerate(options):
177
+ lines = []
178
+ for i, opt in enumerate(options):
215
179
  if i == selected_index[0]:
216
- lines.append(("class:selected", f" ❯ {key:8} "))
217
- lines.append(("class:selected-desc", f"- {desc}\n"))
180
+ lines.append(("class:selected", f" ❯ [{i+1}] {opt}\n"))
218
181
  else:
219
- lines.append(("class:option", f" {key:8} "))
220
- lines.append(("class:desc", f"- {desc}\n"))
221
- lines.append(("class:hint", "\n↑/↓ to move, Enter to select, q to cancel"))
182
+ lines.append(("class:option", f" [{i+1}] {opt}\n"))
183
+ lines.append(("class:hint", "\n↑/↓ to move, Enter to select, 1-9 for quick select, o for other"))
222
184
  return lines
223
185
 
224
186
  # Style
225
187
  style = Style.from_dict({
226
- "title": "#00ccff bold",
227
188
  "selected": "#00cc66 bold",
228
- "selected-desc": "#00cc66",
229
189
  "option": "#888888",
230
- "desc": "#666666",
231
190
  "hint": "#444444 italic",
232
191
  })
233
192
 
193
+ # Calculate height based on options
194
+ height = len(options) + 2 # options + hint line + padding
195
+
234
196
  # Layout
235
197
  layout = Layout(
236
198
  HSplit([
237
199
  Window(
238
200
  FormattedTextControl(get_formatted_options),
239
- height=8,
201
+ height=height,
240
202
  ),
241
203
  ])
242
204
  )
@@ -254,30 +216,48 @@ def _show_spec_approval_menu() -> tuple[str, str]:
254
216
  try:
255
217
  app.run()
256
218
  except (KeyboardInterrupt, EOFError):
257
- result[0] = "abort"
258
-
259
- choice = result[0] or "abort"
219
+ return None
260
220
 
261
- # Get feedback if refine was chosen
262
- feedback = ""
263
- if choice == "refine":
264
- from prompt_toolkit import PromptSession
221
+ # Handle "other" option - get custom input
222
+ if result[0] == "OTHER_INPUT":
223
+ session = PromptSession()
265
224
  console.print()
266
- console.print("[dim]What changes would you like?[/dim]")
267
225
  try:
268
- session = PromptSession()
269
- feedback = session.prompt("feedback > ").strip()
226
+ return session.prompt("response > ").strip() or None
270
227
  except (KeyboardInterrupt, EOFError):
271
- return "abort", ""
228
+ return None
272
229
 
273
- return choice, feedback
230
+ # Check if selected option is an "other/explain" type that needs text input
231
+ if result[0]:
232
+ lower_result = result[0].lower()
233
+ needs_input = any(phrase in lower_result for phrase in [
234
+ "something else",
235
+ "other",
236
+ "i'll explain",
237
+ "i will explain",
238
+ "let me explain",
239
+ "custom",
240
+ "none of the above",
241
+ ])
242
+ if needs_input:
243
+ session = PromptSession()
244
+ console.print()
245
+ console.print("[dim]Please explain:[/dim]")
246
+ try:
247
+ custom_input = session.prompt("response > ").strip()
248
+ if custom_input:
249
+ return custom_input
250
+ except (KeyboardInterrupt, EOFError):
251
+ return None
252
+
253
+ return result[0]
274
254
 
275
255
 
276
- def _show_tasks_approval_menu() -> tuple[str, str]:
277
- """Show tasks approval menu with arrow-key selection.
256
+ def _show_plan_approval_menu() -> tuple[str, str]:
257
+ """Show plan approval menu with simple approve/reject options.
278
258
 
279
259
  Returns:
280
- Tuple of (choice, feedback) where feedback is only set for 'refine'
260
+ Tuple of (choice, feedback) where feedback is only set for 'reject'
281
261
  """
282
262
  from prompt_toolkit import Application
283
263
  from prompt_toolkit.key_binding import KeyBindings
@@ -285,10 +265,8 @@ def _show_tasks_approval_menu() -> tuple[str, str]:
285
265
  from prompt_toolkit.styles import Style
286
266
 
287
267
  options = [
288
- ("code", "Start implementing these tasks"),
289
- ("refine", "Refine tasks with more details"),
290
- ("export", "Export tasks to file"),
291
- ("abort", "Cancel and discard"),
268
+ ("approve", "Approve and start implementation"),
269
+ ("reject", "Reject and provide feedback"),
292
270
  ]
293
271
 
294
272
  selected_index = [0] # Use list to allow mutation in closure
@@ -313,34 +291,26 @@ def _show_tasks_approval_menu() -> tuple[str, str]:
313
291
  event.app.exit()
314
292
 
315
293
  @kb.add("1")
316
- def select_1(event):
317
- result[0] = "code"
294
+ @kb.add("y")
295
+ def select_approve(event):
296
+ result[0] = "approve"
318
297
  event.app.exit()
319
298
 
320
299
  @kb.add("2")
321
- def select_2(event):
322
- result[0] = "refine"
323
- event.app.exit()
324
-
325
- @kb.add("3")
326
- def select_3(event):
327
- result[0] = "export"
328
- event.app.exit()
329
-
330
- @kb.add("4")
331
- def select_4(event):
332
- result[0] = "abort"
300
+ @kb.add("n")
301
+ def select_reject(event):
302
+ result[0] = "reject"
333
303
  event.app.exit()
334
304
 
335
305
  @kb.add("c-c")
336
306
  @kb.add("q")
337
307
  @kb.add("escape")
338
308
  def cancel(event):
339
- result[0] = "abort"
309
+ result[0] = "reject"
340
310
  event.app.exit()
341
311
 
342
312
  def get_formatted_options():
343
- lines = [("class:title", "What would you like to do with these tasks?\n\n")]
313
+ lines = [("class:title", "Approve this plan?\n\n")]
344
314
  for i, (key, desc) in enumerate(options):
345
315
  if i == selected_index[0]:
346
316
  lines.append(("class:selected", f" ❯ {key:8} "))
@@ -348,14 +318,14 @@ def _show_tasks_approval_menu() -> tuple[str, str]:
348
318
  else:
349
319
  lines.append(("class:option", f" {key:8} "))
350
320
  lines.append(("class:desc", f"- {desc}\n"))
351
- lines.append(("class:hint", "\n↑/↓ to move, Enter to select, q to cancel"))
321
+ lines.append(("class:hint", "\n↑/↓ to move, Enter to select, y/n for quick select"))
352
322
  return lines
353
323
 
354
324
  # Style
355
325
  style = Style.from_dict({
356
- "title": "#cc66ff bold", # Purple for tasks
357
- "selected": "#cc66ff bold",
358
- "selected-desc": "#cc66ff",
326
+ "title": "#00ccff bold",
327
+ "selected": "#00cc66 bold",
328
+ "selected-desc": "#00cc66",
359
329
  "option": "#888888",
360
330
  "desc": "#666666",
361
331
  "hint": "#444444 italic",
@@ -366,7 +336,7 @@ def _show_tasks_approval_menu() -> tuple[str, str]:
366
336
  HSplit([
367
337
  Window(
368
338
  FormattedTextControl(get_formatted_options),
369
- height=8,
339
+ height=6,
370
340
  ),
371
341
  ])
372
342
  )
@@ -384,25 +354,50 @@ def _show_tasks_approval_menu() -> tuple[str, str]:
384
354
  try:
385
355
  app.run()
386
356
  except (KeyboardInterrupt, EOFError):
387
- result[0] = "abort"
357
+ result[0] = "reject"
388
358
 
389
- choice = result[0] or "abort"
359
+ choice = result[0] or "reject"
390
360
 
391
- # Get feedback if refine was chosen
361
+ # Get feedback if reject was chosen
392
362
  feedback = ""
393
- if choice == "refine":
363
+ if choice == "reject":
394
364
  from prompt_toolkit import PromptSession
395
365
  console.print()
396
- console.print("[dim]What changes would you like to the tasks?[/dim]")
366
+ console.print("[dim]What changes would you like?[/dim]")
397
367
  try:
398
368
  session = PromptSession()
399
369
  feedback = session.prompt("feedback > ").strip()
400
370
  except (KeyboardInterrupt, EOFError):
401
- return "abort", ""
371
+ return "reject", ""
402
372
 
403
373
  return choice, feedback
404
374
 
405
375
 
376
+ def _render_with_interrupt(renderer: SSERenderer, stream) -> dict:
377
+ """Render stream with ESC key interrupt support.
378
+
379
+ Args:
380
+ renderer: SSE renderer instance
381
+ stream: SSE stream iterator
382
+
383
+ Returns:
384
+ Result dict from renderer, with 'interrupted' flag
385
+ """
386
+ interrupt_event = threading.Event()
387
+
388
+ def on_escape():
389
+ interrupt_event.set()
390
+
391
+ listener = KeyListener(on_escape)
392
+
393
+ try:
394
+ listener.start()
395
+ result = renderer.render_stream(stream, interrupt_event=interrupt_event)
396
+ return result
397
+ finally:
398
+ listener.stop()
399
+
400
+
406
401
  def _run_single_task(
407
402
  client: EmdashClient,
408
403
  renderer: SSERenderer,
@@ -419,7 +414,9 @@ def _run_single_task(
419
414
  max_iterations=max_iterations,
420
415
  options=options,
421
416
  )
422
- renderer.render_stream(stream)
417
+ result = _render_with_interrupt(renderer, stream)
418
+ if result.get("interrupted"):
419
+ console.print("[dim]Task interrupted. You can continue or start a new task.[/dim]")
423
420
  except Exception as e:
424
421
  console.print(f"[red]Error: {e}[/red]")
425
422
  raise click.Abort()
@@ -441,7 +438,9 @@ def _run_slash_command_task(
441
438
  max_iterations=max_iterations,
442
439
  options=options,
443
440
  )
444
- renderer.render_stream(stream)
441
+ result = _render_with_interrupt(renderer, stream)
442
+ if result.get("interrupted"):
443
+ console.print("[dim]Task interrupted.[/dim]")
445
444
  console.print()
446
445
  except Exception as e:
447
446
  console.print(f"[red]Error: {e}[/red]")
@@ -470,7 +469,6 @@ def _run_interactive(
470
469
  # Style for prompt
471
470
  PROMPT_STYLE = Style.from_dict({
472
471
  "prompt.mode.plan": "#ffcc00 bold",
473
- "prompt.mode.tasks": "#cc66ff bold",
474
472
  "prompt.mode.code": "#00cc66 bold",
475
473
  "prompt.prefix": "#888888",
476
474
  "completion-menu": "bg:#1a1a2e #ffffff",
@@ -533,7 +531,6 @@ def _run_interactive(
533
531
  """Get formatted prompt based on current mode."""
534
532
  mode_colors = {
535
533
  AgentMode.PLAN: "class:prompt.mode.plan",
536
- AgentMode.TASKS: "class:prompt.mode.tasks",
537
534
  AgentMode.CODE: "class:prompt.mode.code",
538
535
  }
539
536
  mode_name = current_mode.value
@@ -569,10 +566,6 @@ def _run_interactive(
569
566
  current_mode = AgentMode.PLAN
570
567
  console.print("[yellow]Switched to plan mode[/yellow]")
571
568
 
572
- elif command == "/tasks":
573
- current_mode = AgentMode.TASKS
574
- console.print("[magenta]Switched to tasks mode[/magenta]")
575
-
576
569
  elif command == "/code":
577
570
  current_mode = AgentMode.CODE
578
571
  console.print("[green]Switched to code mode[/green]")
@@ -749,7 +742,7 @@ def _run_interactive(
749
742
  )
750
743
 
751
744
  # Render the stream and capture any spec output
752
- result = renderer.render_stream(stream)
745
+ result = _render_with_interrupt(renderer, stream)
753
746
 
754
747
  # Check if we got a session ID back
755
748
  if result and result.get("session_id"):
@@ -766,108 +759,45 @@ def _run_interactive(
766
759
  if response:
767
760
  # Continue session with user's choice
768
761
  stream = client.agent_continue_stream(session_id, response)
769
- result = renderer.render_stream(stream)
762
+ result = _render_with_interrupt(renderer, stream)
770
763
 
771
- # Update mode if user chose tasks or code
772
- if "tasks" in response.lower():
773
- current_mode = AgentMode.TASKS
774
- elif "code" in response.lower():
764
+ # Update mode if user chose code
765
+ if "code" in response.lower():
775
766
  current_mode = AgentMode.CODE
776
767
 
777
768
  # Handle plan mode completion (show approval menu)
778
- # In plan mode, show menu after any substantial response
769
+ # Only show menu when agent explicitly submits a plan via exit_plan tool
779
770
  content = result.get("content", "")
771
+ plan_submitted = result.get("plan_submitted")
780
772
  should_show_plan_menu = (
781
773
  current_mode == AgentMode.PLAN and
782
774
  session_id and
783
- len(content) > 100 # Has substantial content
775
+ plan_submitted is not None # Agent called exit_plan tool
784
776
  )
785
777
  if should_show_plan_menu:
786
- choice, feedback = _show_spec_approval_menu()
787
-
788
- if choice == "tasks":
789
- current_mode = AgentMode.TASKS
790
- # Include the spec content explicitly in the message
791
- tasks_prompt = f"""Generate implementation tasks from this approved specification.
792
-
793
- ## Approved Specification
794
-
795
- {content}
796
-
797
- ## Your Task
798
-
799
- Use your tools to explore the codebase and create implementation tasks:
800
-
801
- 1. **Explore**: Use semantic_search and code graph tools to find:
802
- - Existing related code to modify
803
- - Patterns and conventions used in the codebase
804
- - Files that will need changes
805
-
806
- 2. **Generate Tasks**: Create detailed tasks that include:
807
- - Task ID (T1, T2, T3...)
808
- - Description of what to implement
809
- - Specific files to modify (based on your exploration)
810
- - Dependencies on other tasks
811
- - Complexity estimate (S/M/L)
812
- - Acceptance criteria
813
-
814
- 3. **Order**: Arrange tasks in implementation order, starting with foundational changes.
815
-
816
- Output a comprehensive task list that a developer can follow step-by-step."""
817
- stream = client.agent_continue_stream(
818
- session_id,
819
- tasks_prompt
820
- )
821
- result = renderer.render_stream(stream)
822
- # After generating tasks, show tasks menu
823
- content = result.get("content", "")
824
- elif choice == "code":
825
- current_mode = AgentMode.CODE
826
- stream = client.agent_continue_stream(
827
- session_id,
828
- "Start implementing the approved spec."
829
- )
830
- renderer.render_stream(stream)
831
- elif choice == "refine":
832
- stream = client.agent_continue_stream(
833
- session_id,
834
- f"Please update the spec based on this feedback: {feedback}"
835
- )
836
- renderer.render_stream(stream)
837
- elif choice == "abort":
838
- console.print("[dim]Spec discarded[/dim]")
839
- session_id = None
840
- current_spec = None
841
-
842
- # Handle tasks mode completion (show tasks approval menu)
843
- should_show_tasks_menu = (
844
- current_mode == AgentMode.TASKS and
845
- session_id and
846
- len(content) > 100 # Has substantial content
847
- )
848
- if should_show_tasks_menu:
849
- choice, feedback = _show_tasks_approval_menu()
778
+ choice, feedback = _show_plan_approval_menu()
850
779
 
851
- if choice == "code":
780
+ if choice == "approve":
852
781
  current_mode = AgentMode.CODE
782
+ # Reset mode state to CODE
783
+ from emdash_core.agent.tools.modes import ModeState, AgentMode as CoreMode
784
+ ModeState.get_instance().current_mode = CoreMode.CODE
853
785
  stream = client.agent_continue_stream(
854
786
  session_id,
855
- "Start implementing the first task from the task list."
856
- )
857
- renderer.render_stream(stream)
858
- elif choice == "refine":
859
- stream = client.agent_continue_stream(
860
- session_id,
861
- f"Please update the tasks based on this feedback: {feedback}"
787
+ "The plan has been approved. Start implementing it now."
862
788
  )
863
- renderer.render_stream(stream)
864
- elif choice == "export":
865
- console.print("[dim]Exporting tasks...[/dim]")
866
- # TODO: Implement export functionality
867
- console.print("[yellow]Export not implemented yet[/yellow]")
868
- elif choice == "abort":
869
- console.print("[dim]Tasks discarded[/dim]")
870
- session_id = None
789
+ _render_with_interrupt(renderer, stream)
790
+ elif choice == "reject":
791
+ if feedback:
792
+ stream = client.agent_continue_stream(
793
+ session_id,
794
+ f"The plan was rejected. Please revise based on this feedback: {feedback}"
795
+ )
796
+ _render_with_interrupt(renderer, stream)
797
+ else:
798
+ console.print("[dim]Plan rejected[/dim]")
799
+ session_id = None
800
+ current_spec = None
871
801
 
872
802
  console.print()
873
803