emdash-cli 0.1.30__py3-none-any.whl → 0.1.46__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 (32) hide show
  1. emdash_cli/__init__.py +15 -0
  2. emdash_cli/client.py +156 -0
  3. emdash_cli/clipboard.py +30 -61
  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 +53 -0
  7. emdash_cli/commands/agent/file_utils.py +178 -0
  8. emdash_cli/commands/agent/handlers/__init__.py +41 -0
  9. emdash_cli/commands/agent/handlers/agents.py +421 -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/mcp.py +183 -0
  14. emdash_cli/commands/agent/handlers/misc.py +200 -0
  15. emdash_cli/commands/agent/handlers/rules.py +394 -0
  16. emdash_cli/commands/agent/handlers/sessions.py +168 -0
  17. emdash_cli/commands/agent/handlers/setup.py +582 -0
  18. emdash_cli/commands/agent/handlers/skills.py +440 -0
  19. emdash_cli/commands/agent/handlers/todos.py +98 -0
  20. emdash_cli/commands/agent/handlers/verify.py +648 -0
  21. emdash_cli/commands/agent/interactive.py +657 -0
  22. emdash_cli/commands/agent/menus.py +728 -0
  23. emdash_cli/commands/agent.py +7 -856
  24. emdash_cli/commands/server.py +99 -40
  25. emdash_cli/server_manager.py +70 -10
  26. emdash_cli/session_store.py +321 -0
  27. emdash_cli/sse_renderer.py +256 -110
  28. {emdash_cli-0.1.30.dist-info → emdash_cli-0.1.46.dist-info}/METADATA +2 -4
  29. emdash_cli-0.1.46.dist-info/RECORD +49 -0
  30. emdash_cli-0.1.30.dist-info/RECORD +0 -29
  31. {emdash_cli-0.1.30.dist-info → emdash_cli-0.1.46.dist-info}/WHEEL +0 -0
  32. {emdash_cli-0.1.30.dist-info → emdash_cli-0.1.46.dist-info}/entry_points.txt +0 -0
@@ -1,859 +1,10 @@
1
- """Agent CLI commands."""
1
+ """Agent CLI commands - backward compatibility module.
2
2
 
3
- import os
4
- import threading
3
+ This file re-exports from the refactored agent/ package for backward compatibility.
4
+ The actual implementation is now in agent/ subdirectory.
5
+ """
5
6
 
6
- import click
7
- from enum import Enum
8
- from rich.console import Console
9
- from rich.panel import Panel
10
- from rich.markdown import Markdown
7
+ # Re-export the agent click group and commands
8
+ from .agent import agent, agent_code
11
9
 
12
- from ..client import EmdashClient
13
- from ..keyboard import KeyListener
14
- from ..server_manager import get_server_manager
15
- from ..sse_renderer import SSERenderer
16
-
17
- console = Console()
18
-
19
-
20
- class AgentMode(Enum):
21
- """Agent operation modes."""
22
- PLAN = "plan"
23
- CODE = "code"
24
-
25
-
26
- # Slash commands available in interactive mode
27
- SLASH_COMMANDS = {
28
- # Mode switching
29
- "/plan": "Switch to plan mode (explore codebase, create plans)",
30
- "/code": "Switch to code mode (execute file changes)",
31
- "/mode": "Show current mode",
32
- # Generation commands
33
- "/pr [url]": "Review a pull request",
34
- "/projectmd": "Generate PROJECT.md for the codebase",
35
- "/research [goal]": "Deep research on a topic",
36
- # Status commands
37
- "/status": "Show index and PROJECT.md status",
38
- # Session management
39
- "/spec": "Show current specification",
40
- "/reset": "Reset session state",
41
- "/save": "Save current spec to disk",
42
- "/help": "Show available commands",
43
- "/quit": "Exit the agent",
44
- }
45
-
46
-
47
- @click.group()
48
- def agent():
49
- """AI agent commands."""
50
- pass
51
-
52
-
53
- @agent.command("code")
54
- @click.argument("task", required=False)
55
- @click.option("--model", "-m", default=None, help="Model to use")
56
- @click.option("--mode", type=click.Choice(["plan", "code"]), default="code",
57
- help="Starting mode")
58
- @click.option("--quiet", "-q", is_flag=True, help="Less verbose output")
59
- @click.option("--max-iterations", default=int(os.getenv("EMDASH_MAX_ITERATIONS", "100")), help="Max agent iterations")
60
- @click.option("--no-graph-tools", is_flag=True, help="Skip graph exploration tools")
61
- @click.option("--save", is_flag=True, help="Save specs to specs/<feature>/")
62
- def agent_code(
63
- task: str | None,
64
- model: str | None,
65
- mode: str,
66
- quiet: bool,
67
- max_iterations: int,
68
- no_graph_tools: bool,
69
- save: bool,
70
- ):
71
- """Start the coding agent.
72
-
73
- With TASK: Run single task and exit
74
- Without TASK: Start interactive REPL mode
75
-
76
- MODES:
77
- plan - Explore codebase and create plans (read-only)
78
- code - Execute code changes (default)
79
-
80
- SLASH COMMANDS (in interactive mode):
81
- /plan - Switch to plan mode
82
- /code - Switch to code mode
83
- /help - Show available commands
84
- /reset - Reset session
85
-
86
- Examples:
87
- emdash # Interactive code mode
88
- emdash agent code # Same as above
89
- emdash agent code --mode plan # Start in plan mode
90
- emdash agent code "Fix the login bug" # Single task
91
- """
92
- # Get server URL (starts server if needed)
93
- server = get_server_manager()
94
- base_url = server.get_server_url()
95
-
96
- client = EmdashClient(base_url)
97
- renderer = SSERenderer(console=console, verbose=not quiet)
98
-
99
- options = {
100
- "mode": mode,
101
- "no_graph_tools": no_graph_tools,
102
- "save": save,
103
- }
104
-
105
- if task:
106
- # Single task mode
107
- _run_single_task(client, renderer, task, model, max_iterations, options)
108
- else:
109
- # Interactive REPL mode
110
- _run_interactive(client, renderer, model, max_iterations, options)
111
-
112
-
113
- def _get_clarification_response(clarification: dict) -> str | None:
114
- """Get user response for clarification with interactive selection.
115
-
116
- Args:
117
- clarification: Dict with question, context, and options
118
-
119
- Returns:
120
- User's selected option or typed response, or None if cancelled
121
- """
122
- from prompt_toolkit import Application, PromptSession
123
- from prompt_toolkit.key_binding import KeyBindings
124
- from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
125
- from prompt_toolkit.styles import Style
126
-
127
- options = clarification.get("options", [])
128
-
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]
138
- result = [None]
139
-
140
- # Key bindings
141
- kb = KeyBindings()
142
-
143
- @kb.add("up")
144
- @kb.add("k")
145
- def move_up(event):
146
- selected_index[0] = (selected_index[0] - 1) % len(options)
147
-
148
- @kb.add("down")
149
- @kb.add("j")
150
- def move_down(event):
151
- selected_index[0] = (selected_index[0] + 1) % len(options)
152
-
153
- @kb.add("enter")
154
- def select(event):
155
- result[0] = options[selected_index[0]]
156
- event.app.exit()
157
-
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()
164
-
165
- @kb.add("c-c")
166
- @kb.add("escape")
167
- def cancel(event):
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"
174
- event.app.exit()
175
-
176
- def get_formatted_options():
177
- lines = []
178
- for i, opt in enumerate(options):
179
- if i == selected_index[0]:
180
- lines.append(("class:selected", f" ❯ [{i+1}] {opt}\n"))
181
- else:
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"))
184
- return lines
185
-
186
- # Style
187
- style = Style.from_dict({
188
- "selected": "#00cc66 bold",
189
- "option": "#888888",
190
- "hint": "#444444 italic",
191
- })
192
-
193
- # Calculate height based on options
194
- height = len(options) + 2 # options + hint line + padding
195
-
196
- # Layout
197
- layout = Layout(
198
- HSplit([
199
- Window(
200
- FormattedTextControl(get_formatted_options),
201
- height=height,
202
- ),
203
- ])
204
- )
205
-
206
- # Application
207
- app = Application(
208
- layout=layout,
209
- key_bindings=kb,
210
- style=style,
211
- full_screen=False,
212
- )
213
-
214
- console.print()
215
-
216
- try:
217
- app.run()
218
- except (KeyboardInterrupt, EOFError):
219
- return None
220
-
221
- # Handle "other" option - get custom input
222
- if result[0] == "OTHER_INPUT":
223
- session = PromptSession()
224
- console.print()
225
- try:
226
- return session.prompt("response > ").strip() or None
227
- except (KeyboardInterrupt, EOFError):
228
- return None
229
-
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]
254
-
255
-
256
- def _show_plan_approval_menu() -> tuple[str, str]:
257
- """Show plan approval menu with simple approve/reject options.
258
-
259
- Returns:
260
- Tuple of (choice, feedback) where feedback is only set for 'reject'
261
- """
262
- from prompt_toolkit import Application
263
- from prompt_toolkit.key_binding import KeyBindings
264
- from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
265
- from prompt_toolkit.styles import Style
266
-
267
- options = [
268
- ("approve", "Approve and start implementation"),
269
- ("reject", "Reject and provide feedback"),
270
- ]
271
-
272
- selected_index = [0] # Use list to allow mutation in closure
273
- result = [None]
274
-
275
- # Key bindings
276
- kb = KeyBindings()
277
-
278
- @kb.add("up")
279
- @kb.add("k")
280
- def move_up(event):
281
- selected_index[0] = (selected_index[0] - 1) % len(options)
282
-
283
- @kb.add("down")
284
- @kb.add("j")
285
- def move_down(event):
286
- selected_index[0] = (selected_index[0] + 1) % len(options)
287
-
288
- @kb.add("enter")
289
- def select(event):
290
- result[0] = options[selected_index[0]][0]
291
- event.app.exit()
292
-
293
- @kb.add("1")
294
- @kb.add("y")
295
- def select_approve(event):
296
- result[0] = "approve"
297
- event.app.exit()
298
-
299
- @kb.add("2")
300
- @kb.add("n")
301
- def select_reject(event):
302
- result[0] = "reject"
303
- event.app.exit()
304
-
305
- @kb.add("c-c")
306
- @kb.add("q")
307
- @kb.add("escape")
308
- def cancel(event):
309
- result[0] = "reject"
310
- event.app.exit()
311
-
312
- def get_formatted_options():
313
- lines = [("class:title", "Approve this plan?\n\n")]
314
- for i, (key, desc) in enumerate(options):
315
- if i == selected_index[0]:
316
- lines.append(("class:selected", f" ❯ {key:8} "))
317
- lines.append(("class:selected-desc", f"- {desc}\n"))
318
- else:
319
- lines.append(("class:option", f" {key:8} "))
320
- lines.append(("class:desc", f"- {desc}\n"))
321
- lines.append(("class:hint", "\n↑/↓ to move, Enter to select, y/n for quick select"))
322
- return lines
323
-
324
- # Style
325
- style = Style.from_dict({
326
- "title": "#00ccff bold",
327
- "selected": "#00cc66 bold",
328
- "selected-desc": "#00cc66",
329
- "option": "#888888",
330
- "desc": "#666666",
331
- "hint": "#444444 italic",
332
- })
333
-
334
- # Layout
335
- layout = Layout(
336
- HSplit([
337
- Window(
338
- FormattedTextControl(get_formatted_options),
339
- height=6,
340
- ),
341
- ])
342
- )
343
-
344
- # Application
345
- app = Application(
346
- layout=layout,
347
- key_bindings=kb,
348
- style=style,
349
- full_screen=False,
350
- )
351
-
352
- console.print()
353
-
354
- try:
355
- app.run()
356
- except (KeyboardInterrupt, EOFError):
357
- result[0] = "reject"
358
-
359
- choice = result[0] or "reject"
360
-
361
- # Get feedback if reject was chosen
362
- feedback = ""
363
- if choice == "reject":
364
- from prompt_toolkit import PromptSession
365
- console.print()
366
- console.print("[dim]What changes would you like?[/dim]")
367
- try:
368
- session = PromptSession()
369
- feedback = session.prompt("feedback > ").strip()
370
- except (KeyboardInterrupt, EOFError):
371
- return "reject", ""
372
-
373
- return choice, feedback
374
-
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
-
401
- def _run_single_task(
402
- client: EmdashClient,
403
- renderer: SSERenderer,
404
- task: str,
405
- model: str | None,
406
- max_iterations: int,
407
- options: dict,
408
- ):
409
- """Run a single agent task."""
410
- try:
411
- stream = client.agent_chat_stream(
412
- message=task,
413
- model=model,
414
- max_iterations=max_iterations,
415
- options=options,
416
- )
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]")
420
- except Exception as e:
421
- console.print(f"[red]Error: {e}[/red]")
422
- raise click.Abort()
423
-
424
-
425
- def _run_slash_command_task(
426
- client: EmdashClient,
427
- renderer: SSERenderer,
428
- model: str | None,
429
- max_iterations: int,
430
- task: str,
431
- options: dict,
432
- ):
433
- """Run a task from a slash command."""
434
- try:
435
- stream = client.agent_chat_stream(
436
- message=task,
437
- model=model,
438
- max_iterations=max_iterations,
439
- options=options,
440
- )
441
- result = _render_with_interrupt(renderer, stream)
442
- if result.get("interrupted"):
443
- console.print("[dim]Task interrupted.[/dim]")
444
- console.print()
445
- except Exception as e:
446
- console.print(f"[red]Error: {e}[/red]")
447
-
448
-
449
- def _run_interactive(
450
- client: EmdashClient,
451
- renderer: SSERenderer,
452
- model: str | None,
453
- max_iterations: int,
454
- options: dict,
455
- ):
456
- """Run interactive REPL mode with slash commands."""
457
- from prompt_toolkit import PromptSession
458
- from prompt_toolkit.history import FileHistory
459
- from prompt_toolkit.completion import Completer, Completion
460
- from prompt_toolkit.styles import Style
461
- from prompt_toolkit.key_binding import KeyBindings
462
- from pathlib import Path
463
-
464
- # Current mode
465
- current_mode = AgentMode(options.get("mode", "code"))
466
- session_id = None
467
- current_spec = None
468
- # Attached images for next message
469
- attached_images: list[dict] = []
470
-
471
- # Style for prompt
472
- PROMPT_STYLE = Style.from_dict({
473
- "prompt.mode.plan": "#ffcc00 bold",
474
- "prompt.mode.code": "#00cc66 bold",
475
- "prompt.prefix": "#888888",
476
- "prompt.image": "#00ccff",
477
- "completion-menu": "bg:#1a1a2e #ffffff",
478
- "completion-menu.completion": "bg:#1a1a2e #ffffff",
479
- "completion-menu.completion.current": "bg:#4a4a6e #ffffff bold",
480
- "completion-menu.meta.completion": "bg:#1a1a2e #888888",
481
- "completion-menu.meta.completion.current": "bg:#4a4a6e #aaaaaa",
482
- "command": "#00ccff bold",
483
- })
484
-
485
- class SlashCommandCompleter(Completer):
486
- """Completer for slash commands."""
487
-
488
- def get_completions(self, document, complete_event):
489
- text = document.text_before_cursor
490
- if not text.startswith("/"):
491
- return
492
- for cmd, description in SLASH_COMMANDS.items():
493
- # Extract base command (e.g., "/pr" from "/pr [url]")
494
- base_cmd = cmd.split()[0]
495
- if base_cmd.startswith(text):
496
- yield Completion(
497
- base_cmd,
498
- start_position=-len(text),
499
- display=cmd,
500
- display_meta=description,
501
- )
502
-
503
- # Setup history file
504
- history_file = Path.home() / ".emdash" / "cli_history"
505
- history_file.parent.mkdir(parents=True, exist_ok=True)
506
- history = FileHistory(str(history_file))
507
-
508
- # Key bindings: Enter submits, Alt+Enter inserts newline
509
- # Note: Shift+Enter is indistinguishable from Enter in most terminals
510
- kb = KeyBindings()
511
-
512
- @kb.add("enter")
513
- def submit_on_enter(event):
514
- """Submit on Enter."""
515
- event.current_buffer.validate_and_handle()
516
-
517
- @kb.add("escape", "enter") # Alt+Enter (Escape then Enter)
518
- @kb.add("c-j") # Ctrl+J as alternative for newline
519
- def insert_newline_alt(event):
520
- """Insert a newline character with Alt+Enter or Ctrl+J."""
521
- event.current_buffer.insert_text("\n")
522
-
523
- @kb.add("c-v") # Ctrl+V to paste (check for images)
524
- def paste_with_image_check(event):
525
- """Paste text or attach image from clipboard."""
526
- nonlocal attached_images
527
- from ..clipboard import get_clipboard_image
528
-
529
- # Try to get image from clipboard
530
- image_data = get_clipboard_image()
531
- if image_data:
532
- base64_data, img_format = image_data
533
- attached_images.append({"data": base64_data, "format": img_format})
534
- console.print(f"[green]📎 Image attached[/green] [dim]({img_format})[/dim]")
535
- else:
536
- # No image, do normal paste
537
- event.current_buffer.paste_clipboard_data(event.app.clipboard.get_data())
538
-
539
- session = PromptSession(
540
- history=history,
541
- completer=SlashCommandCompleter(),
542
- style=PROMPT_STYLE,
543
- complete_while_typing=True,
544
- multiline=True,
545
- prompt_continuation="... ",
546
- key_bindings=kb,
547
- )
548
-
549
- def get_prompt():
550
- """Get formatted prompt."""
551
- nonlocal attached_images
552
- parts = []
553
- # Add image indicator if images attached
554
- if attached_images:
555
- parts.append(("class:prompt.image", f"📎{len(attached_images)} "))
556
- parts.append(("class:prompt.prefix", "> "))
557
- return parts
558
-
559
- def show_help():
560
- """Show available commands."""
561
- console.print()
562
- console.print("[bold cyan]Available Commands[/bold cyan]")
563
- console.print()
564
- for cmd, desc in SLASH_COMMANDS.items():
565
- console.print(f" [cyan]{cmd:12}[/cyan] {desc}")
566
- console.print()
567
- console.print("[dim]Type your task or question to interact with the agent.[/dim]")
568
- console.print()
569
-
570
- def handle_slash_command(cmd: str) -> bool:
571
- """Handle a slash command. Returns True if should continue, False to exit."""
572
- nonlocal current_mode, session_id, current_spec
573
-
574
- cmd_parts = cmd.strip().split(maxsplit=1)
575
- command = cmd_parts[0].lower()
576
- args = cmd_parts[1] if len(cmd_parts) > 1 else ""
577
-
578
- if command == "/quit" or command == "/exit" or command == "/q":
579
- return False
580
-
581
- elif command == "/help":
582
- show_help()
583
-
584
- elif command == "/plan":
585
- current_mode = AgentMode.PLAN
586
- console.print("[yellow]Switched to plan mode[/yellow]")
587
-
588
- elif command == "/code":
589
- current_mode = AgentMode.CODE
590
- console.print("[green]Switched to code mode[/green]")
591
-
592
- elif command == "/mode":
593
- console.print(f"Current mode: [bold]{current_mode.value}[/bold]")
594
-
595
- elif command == "/reset":
596
- session_id = None
597
- current_spec = None
598
- console.print("[dim]Session reset[/dim]")
599
-
600
- elif command == "/spec":
601
- if current_spec:
602
- console.print(Panel(Markdown(current_spec), title="Current Spec"))
603
- else:
604
- console.print("[dim]No spec available. Use plan mode to create one.[/dim]")
605
-
606
- elif command == "/save":
607
- if current_spec:
608
- # TODO: Save spec via API
609
- console.print("[yellow]Save not implemented yet[/yellow]")
610
- else:
611
- console.print("[dim]No spec to save[/dim]")
612
-
613
- elif command == "/pr":
614
- # PR review
615
- if not args:
616
- console.print("[yellow]Usage: /pr <pr-url-or-number>[/yellow]")
617
- console.print("[dim]Example: /pr 123 or /pr https://github.com/org/repo/pull/123[/dim]")
618
- else:
619
- console.print(f"[cyan]Reviewing PR: {args}[/cyan]")
620
- _run_slash_command_task(
621
- client, renderer, model, max_iterations,
622
- f"Review this pull request and provide feedback: {args}",
623
- {"mode": "code"}
624
- )
625
-
626
- elif command == "/projectmd":
627
- # Generate PROJECT.md
628
- console.print("[cyan]Generating PROJECT.md...[/cyan]")
629
- _run_slash_command_task(
630
- client, renderer, model, max_iterations,
631
- "Analyze this codebase and generate a comprehensive PROJECT.md file that describes the architecture, main components, how to get started, and key design decisions.",
632
- {"mode": "code"}
633
- )
634
-
635
- elif command == "/research":
636
- # Deep research
637
- if not args:
638
- console.print("[yellow]Usage: /research <goal>[/yellow]")
639
- console.print("[dim]Example: /research How does authentication work in this codebase?[/dim]")
640
- else:
641
- console.print(f"[cyan]Researching: {args}[/cyan]")
642
- _run_slash_command_task(
643
- client, renderer, model, 50, # More iterations for research
644
- f"Conduct deep research on: {args}\n\nExplore the codebase thoroughly, analyze relevant code, and provide a comprehensive answer with references to specific files and functions.",
645
- {"mode": "plan"} # Use plan mode for research
646
- )
647
-
648
- elif command == "/status":
649
- # Show index and PROJECT.md status
650
- from datetime import datetime
651
-
652
- console.print("\n[bold cyan]Status[/bold cyan]\n")
653
-
654
- # Index status
655
- console.print("[bold]Index Status[/bold]")
656
- try:
657
- status = client.index_status(str(Path.cwd()))
658
- is_indexed = status.get("is_indexed", False)
659
- console.print(f" Indexed: {'[green]Yes[/green]' if is_indexed else '[yellow]No[/yellow]'}")
660
-
661
- if is_indexed:
662
- console.print(f" Files: {status.get('file_count', 0)}")
663
- console.print(f" Functions: {status.get('function_count', 0)}")
664
- console.print(f" Classes: {status.get('class_count', 0)}")
665
- console.print(f" Communities: {status.get('community_count', 0)}")
666
- if status.get("last_indexed"):
667
- console.print(f" Last indexed: {status.get('last_indexed')}")
668
- if status.get("last_commit"):
669
- console.print(f" Last commit: {status.get('last_commit')}")
670
- except Exception as e:
671
- console.print(f" [red]Error fetching index status: {e}[/red]")
672
-
673
- console.print()
674
-
675
- # PROJECT.md status
676
- console.print("[bold]PROJECT.md Status[/bold]")
677
- projectmd_path = Path.cwd() / "PROJECT.md"
678
- if projectmd_path.exists():
679
- stat = projectmd_path.stat()
680
- modified_time = datetime.fromtimestamp(stat.st_mtime)
681
- size_kb = stat.st_size / 1024
682
- console.print(f" Exists: [green]Yes[/green]")
683
- console.print(f" Path: {projectmd_path}")
684
- console.print(f" Size: {size_kb:.1f} KB")
685
- console.print(f" Last modified: {modified_time.strftime('%Y-%m-%d %H:%M:%S')}")
686
- else:
687
- console.print(f" Exists: [yellow]No[/yellow]")
688
- console.print("[dim] Run /projectmd to generate it[/dim]")
689
-
690
- console.print()
691
-
692
- else:
693
- console.print(f"[yellow]Unknown command: {command}[/yellow]")
694
- console.print("[dim]Type /help for available commands[/dim]")
695
-
696
- return True
697
-
698
- # Show welcome message
699
- from .. import __version__
700
- import subprocess
701
-
702
- # Get current working directory
703
- cwd = Path.cwd()
704
-
705
- # Get git repo name (if in a git repo)
706
- git_repo = None
707
- try:
708
- result = subprocess.run(
709
- ["git", "rev-parse", "--show-toplevel"],
710
- capture_output=True, text=True, cwd=cwd
711
- )
712
- if result.returncode == 0:
713
- git_repo = Path(result.stdout.strip()).name
714
- except Exception:
715
- pass
716
-
717
- # Welcome banner
718
- console.print()
719
- console.print(f"[bold cyan]Emdash Code[/bold cyan] [dim]v{__version__}[/dim]")
720
- if git_repo:
721
- console.print(f"[dim]Repo:[/dim] [bold green]{git_repo}[/bold green] [dim]| Mode:[/dim] [bold]{current_mode.value}[/bold] [dim]| Model:[/dim] {model or 'default'}")
722
- else:
723
- console.print(f"[dim]Mode:[/dim] [bold]{current_mode.value}[/bold] [dim]| Model:[/dim] {model or 'default'}")
724
- console.print()
725
-
726
- while True:
727
- try:
728
- # Get user input
729
- user_input = session.prompt(get_prompt()).strip()
730
-
731
- if not user_input:
732
- continue
733
-
734
- # Handle slash commands
735
- if user_input.startswith("/"):
736
- if not handle_slash_command(user_input):
737
- break
738
- continue
739
-
740
- # Handle quit shortcuts
741
- if user_input.lower() in ("quit", "exit", "q"):
742
- break
743
-
744
- # Build options with current mode
745
- request_options = {
746
- **options,
747
- "mode": current_mode.value,
748
- }
749
-
750
- # Run agent with current mode
751
- try:
752
- # Prepare images for API call
753
- images_to_send = attached_images if attached_images else None
754
-
755
- if session_id:
756
- stream = client.agent_continue_stream(
757
- session_id, user_input, images=images_to_send
758
- )
759
- else:
760
- stream = client.agent_chat_stream(
761
- message=user_input,
762
- model=model,
763
- max_iterations=max_iterations,
764
- options=request_options,
765
- images=images_to_send,
766
- )
767
-
768
- # Clear attached images after sending
769
- attached_images = []
770
-
771
- # Render the stream and capture any spec output
772
- result = _render_with_interrupt(renderer, stream)
773
-
774
- # Check if we got a session ID back
775
- if result and result.get("session_id"):
776
- session_id = result["session_id"]
777
-
778
- # Check for spec output
779
- if result and result.get("spec"):
780
- current_spec = result["spec"]
781
-
782
- # Handle clarification with options (interactive selection)
783
- clarification = result.get("clarification")
784
- if clarification and clarification.get("options") and session_id:
785
- response = _get_clarification_response(clarification)
786
- if response:
787
- # Continue session with user's choice
788
- stream = client.agent_continue_stream(session_id, response)
789
- result = _render_with_interrupt(renderer, stream)
790
-
791
- # Update mode if user chose code
792
- if "code" in response.lower():
793
- current_mode = AgentMode.CODE
794
-
795
- # Handle plan mode completion (show approval menu)
796
- # Only show menu when agent explicitly submits a plan via exit_plan tool
797
- content = result.get("content", "")
798
- plan_submitted = result.get("plan_submitted")
799
- should_show_plan_menu = (
800
- current_mode == AgentMode.PLAN and
801
- session_id and
802
- plan_submitted is not None # Agent called exit_plan tool
803
- )
804
- if should_show_plan_menu:
805
- choice, feedback = _show_plan_approval_menu()
806
-
807
- if choice == "approve":
808
- current_mode = AgentMode.CODE
809
- # Reset mode state to CODE
810
- from emdash_core.agent.tools.modes import ModeState, AgentMode as CoreMode
811
- ModeState.get_instance().current_mode = CoreMode.CODE
812
- stream = client.agent_continue_stream(
813
- session_id,
814
- "The plan has been approved. Start implementing it now."
815
- )
816
- _render_with_interrupt(renderer, stream)
817
- elif choice == "reject":
818
- if feedback:
819
- stream = client.agent_continue_stream(
820
- session_id,
821
- f"The plan was rejected. Please revise based on this feedback: {feedback}"
822
- )
823
- _render_with_interrupt(renderer, stream)
824
- else:
825
- console.print("[dim]Plan rejected[/dim]")
826
- session_id = None
827
- current_spec = None
828
-
829
- console.print()
830
-
831
- except Exception as e:
832
- console.print(f"[red]Error: {e}[/red]")
833
-
834
- except KeyboardInterrupt:
835
- console.print("\n[dim]Interrupted[/dim]")
836
- break
837
- except EOFError:
838
- break
839
-
840
-
841
- @agent.command("sessions")
842
- def list_sessions():
843
- """List active agent sessions."""
844
- server = get_server_manager()
845
- base_url = server.get_server_url()
846
-
847
- client = EmdashClient(base_url)
848
- sessions = client.list_sessions()
849
-
850
- if not sessions:
851
- console.print("[dim]No active sessions[/dim]")
852
- return
853
-
854
- for s in sessions:
855
- console.print(
856
- f" {s['session_id'][:8]}... "
857
- f"[dim]({s.get('model', 'unknown')}, "
858
- f"{s.get('message_count', 0)} messages)[/dim]"
859
- )
10
+ __all__ = ["agent", "agent_code"]