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,883 @@
1
+ """Agent CLI commands."""
2
+
3
+ import click
4
+ from enum import Enum
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.markdown import Markdown
8
+
9
+ from ..client import EmdashClient
10
+ from ..server_manager import get_server_manager
11
+ from ..sse_renderer import SSERenderer
12
+
13
+ console = Console()
14
+
15
+
16
+ class AgentMode(Enum):
17
+ """Agent operation modes."""
18
+ PLAN = "plan"
19
+ TASKS = "tasks"
20
+ CODE = "code"
21
+
22
+
23
+ # Slash commands available in interactive mode
24
+ SLASH_COMMANDS = {
25
+ # Mode switching
26
+ "/plan": "Switch to plan mode (explore codebase, create specs)",
27
+ "/tasks": "Switch to tasks mode (generate task lists)",
28
+ "/code": "Switch to code mode (execute file changes)",
29
+ "/mode": "Show current mode",
30
+ # Generation commands
31
+ "/pr [url]": "Review a pull request",
32
+ "/projectmd": "Generate PROJECT.md for the codebase",
33
+ "/research [goal]": "Deep research on a topic",
34
+ # Status commands
35
+ "/status": "Show index and PROJECT.md status",
36
+ # Session management
37
+ "/spec": "Show current specification",
38
+ "/reset": "Reset session state",
39
+ "/save": "Save current spec to disk",
40
+ "/help": "Show available commands",
41
+ "/quit": "Exit the agent",
42
+ }
43
+
44
+
45
+ @click.group()
46
+ def agent():
47
+ """AI agent commands."""
48
+ pass
49
+
50
+
51
+ @agent.command("code")
52
+ @click.argument("task", required=False)
53
+ @click.option("--model", "-m", default=None, help="Model to use")
54
+ @click.option("--mode", type=click.Choice(["plan", "tasks", "code"]), default="code",
55
+ help="Starting mode")
56
+ @click.option("--quiet", "-q", is_flag=True, help="Less verbose output")
57
+ @click.option("--max-iterations", default=20, help="Max agent iterations")
58
+ @click.option("--no-graph-tools", is_flag=True, help="Skip graph exploration tools")
59
+ @click.option("--save", is_flag=True, help="Save specs to specs/<feature>/")
60
+ def agent_code(
61
+ task: str | None,
62
+ model: str | None,
63
+ mode: str,
64
+ quiet: bool,
65
+ max_iterations: int,
66
+ no_graph_tools: bool,
67
+ save: bool,
68
+ ):
69
+ """Start the coding agent.
70
+
71
+ With TASK: Run single task and exit
72
+ Without TASK: Start interactive REPL mode
73
+
74
+ MODES:
75
+ plan - Explore codebase and create specifications
76
+ tasks - Generate implementation task lists
77
+ code - Execute code changes (default)
78
+
79
+ SLASH COMMANDS (in interactive mode):
80
+ /plan - Switch to plan mode
81
+ /tasks - Switch to tasks 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 options.
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 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
153
+ from prompt_toolkit.key_binding import KeyBindings
154
+ from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
155
+ from prompt_toolkit.styles import Style
156
+
157
+ options = [
158
+ ("tasks", "Generate implementation tasks"),
159
+ ("code", "Start coding directly"),
160
+ ("refine", "Provide feedback to improve"),
161
+ ("abort", "Cancel and discard"),
162
+ ]
163
+
164
+ selected_index = [0] # Use list to allow mutation in closure
165
+ result = [None]
166
+
167
+ # Key bindings
168
+ kb = KeyBindings()
169
+
170
+ @kb.add("up")
171
+ @kb.add("k")
172
+ def move_up(event):
173
+ selected_index[0] = (selected_index[0] - 1) % len(options)
174
+
175
+ @kb.add("down")
176
+ @kb.add("j")
177
+ def move_down(event):
178
+ selected_index[0] = (selected_index[0] + 1) % len(options)
179
+
180
+ @kb.add("enter")
181
+ 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"
193
+ event.app.exit()
194
+
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()
204
+
205
+ @kb.add("c-c")
206
+ @kb.add("q")
207
+ @kb.add("escape")
208
+ def cancel(event):
209
+ result[0] = "abort"
210
+ event.app.exit()
211
+
212
+ 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):
215
+ if i == selected_index[0]:
216
+ lines.append(("class:selected", f" ❯ {key:8} "))
217
+ lines.append(("class:selected-desc", f"- {desc}\n"))
218
+ 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"))
222
+ return lines
223
+
224
+ # Style
225
+ style = Style.from_dict({
226
+ "title": "#00ccff bold",
227
+ "selected": "#00cc66 bold",
228
+ "selected-desc": "#00cc66",
229
+ "option": "#888888",
230
+ "desc": "#666666",
231
+ "hint": "#444444 italic",
232
+ })
233
+
234
+ # Layout
235
+ layout = Layout(
236
+ HSplit([
237
+ Window(
238
+ FormattedTextControl(get_formatted_options),
239
+ height=8,
240
+ ),
241
+ ])
242
+ )
243
+
244
+ # Application
245
+ app = Application(
246
+ layout=layout,
247
+ key_bindings=kb,
248
+ style=style,
249
+ full_screen=False,
250
+ )
251
+
252
+ console.print()
253
+
254
+ try:
255
+ app.run()
256
+ except (KeyboardInterrupt, EOFError):
257
+ result[0] = "abort"
258
+
259
+ choice = result[0] or "abort"
260
+
261
+ # Get feedback if refine was chosen
262
+ feedback = ""
263
+ if choice == "refine":
264
+ from prompt_toolkit import PromptSession
265
+ console.print()
266
+ console.print("[dim]What changes would you like?[/dim]")
267
+ try:
268
+ session = PromptSession()
269
+ feedback = session.prompt("feedback > ").strip()
270
+ except (KeyboardInterrupt, EOFError):
271
+ return "abort", ""
272
+
273
+ return choice, feedback
274
+
275
+
276
+ def _show_tasks_approval_menu() -> tuple[str, str]:
277
+ """Show tasks approval menu with arrow-key selection.
278
+
279
+ Returns:
280
+ Tuple of (choice, feedback) where feedback is only set for 'refine'
281
+ """
282
+ from prompt_toolkit import Application
283
+ from prompt_toolkit.key_binding import KeyBindings
284
+ from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
285
+ from prompt_toolkit.styles import Style
286
+
287
+ 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"),
292
+ ]
293
+
294
+ selected_index = [0] # Use list to allow mutation in closure
295
+ result = [None]
296
+
297
+ # Key bindings
298
+ kb = KeyBindings()
299
+
300
+ @kb.add("up")
301
+ @kb.add("k")
302
+ def move_up(event):
303
+ selected_index[0] = (selected_index[0] - 1) % len(options)
304
+
305
+ @kb.add("down")
306
+ @kb.add("j")
307
+ def move_down(event):
308
+ selected_index[0] = (selected_index[0] + 1) % len(options)
309
+
310
+ @kb.add("enter")
311
+ def select(event):
312
+ result[0] = options[selected_index[0]][0]
313
+ event.app.exit()
314
+
315
+ @kb.add("1")
316
+ def select_1(event):
317
+ result[0] = "code"
318
+ event.app.exit()
319
+
320
+ @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"
333
+ event.app.exit()
334
+
335
+ @kb.add("c-c")
336
+ @kb.add("q")
337
+ @kb.add("escape")
338
+ def cancel(event):
339
+ result[0] = "abort"
340
+ event.app.exit()
341
+
342
+ def get_formatted_options():
343
+ lines = [("class:title", "What would you like to do with these tasks?\n\n")]
344
+ for i, (key, desc) in enumerate(options):
345
+ if i == selected_index[0]:
346
+ lines.append(("class:selected", f" ❯ {key:8} "))
347
+ lines.append(("class:selected-desc", f"- {desc}\n"))
348
+ else:
349
+ lines.append(("class:option", f" {key:8} "))
350
+ lines.append(("class:desc", f"- {desc}\n"))
351
+ lines.append(("class:hint", "\n↑/↓ to move, Enter to select, q to cancel"))
352
+ return lines
353
+
354
+ # Style
355
+ style = Style.from_dict({
356
+ "title": "#cc66ff bold", # Purple for tasks
357
+ "selected": "#cc66ff bold",
358
+ "selected-desc": "#cc66ff",
359
+ "option": "#888888",
360
+ "desc": "#666666",
361
+ "hint": "#444444 italic",
362
+ })
363
+
364
+ # Layout
365
+ layout = Layout(
366
+ HSplit([
367
+ Window(
368
+ FormattedTextControl(get_formatted_options),
369
+ height=8,
370
+ ),
371
+ ])
372
+ )
373
+
374
+ # Application
375
+ app = Application(
376
+ layout=layout,
377
+ key_bindings=kb,
378
+ style=style,
379
+ full_screen=False,
380
+ )
381
+
382
+ console.print()
383
+
384
+ try:
385
+ app.run()
386
+ except (KeyboardInterrupt, EOFError):
387
+ result[0] = "abort"
388
+
389
+ choice = result[0] or "abort"
390
+
391
+ # Get feedback if refine was chosen
392
+ feedback = ""
393
+ if choice == "refine":
394
+ from prompt_toolkit import PromptSession
395
+ console.print()
396
+ console.print("[dim]What changes would you like to the tasks?[/dim]")
397
+ try:
398
+ session = PromptSession()
399
+ feedback = session.prompt("feedback > ").strip()
400
+ except (KeyboardInterrupt, EOFError):
401
+ return "abort", ""
402
+
403
+ return choice, feedback
404
+
405
+
406
+ def _run_single_task(
407
+ client: EmdashClient,
408
+ renderer: SSERenderer,
409
+ task: str,
410
+ model: str | None,
411
+ max_iterations: int,
412
+ options: dict,
413
+ ):
414
+ """Run a single agent task."""
415
+ try:
416
+ stream = client.agent_chat_stream(
417
+ message=task,
418
+ model=model,
419
+ max_iterations=max_iterations,
420
+ options=options,
421
+ )
422
+ renderer.render_stream(stream)
423
+ except Exception as e:
424
+ console.print(f"[red]Error: {e}[/red]")
425
+ raise click.Abort()
426
+
427
+
428
+ def _run_slash_command_task(
429
+ client: EmdashClient,
430
+ renderer: SSERenderer,
431
+ model: str | None,
432
+ max_iterations: int,
433
+ task: str,
434
+ options: dict,
435
+ ):
436
+ """Run a task from a slash command."""
437
+ try:
438
+ stream = client.agent_chat_stream(
439
+ message=task,
440
+ model=model,
441
+ max_iterations=max_iterations,
442
+ options=options,
443
+ )
444
+ renderer.render_stream(stream)
445
+ console.print()
446
+ except Exception as e:
447
+ console.print(f"[red]Error: {e}[/red]")
448
+
449
+
450
+ def _run_interactive(
451
+ client: EmdashClient,
452
+ renderer: SSERenderer,
453
+ model: str | None,
454
+ max_iterations: int,
455
+ options: dict,
456
+ ):
457
+ """Run interactive REPL mode with slash commands."""
458
+ from prompt_toolkit import PromptSession
459
+ from prompt_toolkit.history import FileHistory
460
+ from prompt_toolkit.completion import Completer, Completion
461
+ from prompt_toolkit.styles import Style
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
+
469
+ # Style for prompt
470
+ PROMPT_STYLE = Style.from_dict({
471
+ "prompt.mode.plan": "#ffcc00 bold",
472
+ "prompt.mode.tasks": "#cc66ff bold",
473
+ "prompt.mode.code": "#00cc66 bold",
474
+ "prompt.prefix": "#888888",
475
+ "completion-menu": "bg:#1a1a2e #ffffff",
476
+ "completion-menu.completion": "bg:#1a1a2e #ffffff",
477
+ "completion-menu.completion.current": "bg:#4a4a6e #ffffff bold",
478
+ "completion-menu.meta.completion": "bg:#1a1a2e #888888",
479
+ "completion-menu.meta.completion.current": "bg:#4a4a6e #aaaaaa",
480
+ "command": "#00ccff bold",
481
+ })
482
+
483
+ class SlashCommandCompleter(Completer):
484
+ """Completer for slash commands."""
485
+
486
+ def get_completions(self, document, complete_event):
487
+ text = document.text_before_cursor
488
+ if not text.startswith("/"):
489
+ return
490
+ for cmd, description in SLASH_COMMANDS.items():
491
+ # Extract base command (e.g., "/pr" from "/pr [url]")
492
+ base_cmd = cmd.split()[0]
493
+ if base_cmd.startswith(text):
494
+ yield Completion(
495
+ base_cmd,
496
+ start_position=-len(text),
497
+ display=cmd,
498
+ display_meta=description,
499
+ )
500
+
501
+ # Setup history file
502
+ history_file = Path.home() / ".emdash" / "cli_history"
503
+ history_file.parent.mkdir(parents=True, exist_ok=True)
504
+ history = FileHistory(str(history_file))
505
+
506
+ session = PromptSession(
507
+ history=history,
508
+ completer=SlashCommandCompleter(),
509
+ style=PROMPT_STYLE,
510
+ complete_while_typing=True,
511
+ )
512
+
513
+ def get_prompt():
514
+ """Get formatted prompt based on current mode."""
515
+ mode_colors = {
516
+ AgentMode.PLAN: "class:prompt.mode.plan",
517
+ AgentMode.TASKS: "class:prompt.mode.tasks",
518
+ AgentMode.CODE: "class:prompt.mode.code",
519
+ }
520
+ mode_name = current_mode.value
521
+ color_class = mode_colors.get(current_mode, "class:prompt.mode.code")
522
+ return [(color_class, f"[{mode_name}]"), ("", " "), ("class:prompt.prefix", "> ")]
523
+
524
+ def show_help():
525
+ """Show available commands."""
526
+ console.print()
527
+ console.print("[bold cyan]Available Commands[/bold cyan]")
528
+ console.print()
529
+ for cmd, desc in SLASH_COMMANDS.items():
530
+ console.print(f" [cyan]{cmd:12}[/cyan] {desc}")
531
+ console.print()
532
+ console.print("[dim]Type your task or question to interact with the agent.[/dim]")
533
+ console.print()
534
+
535
+ def handle_slash_command(cmd: str) -> bool:
536
+ """Handle a slash command. Returns True if should continue, False to exit."""
537
+ nonlocal current_mode, session_id, current_spec
538
+
539
+ cmd_parts = cmd.strip().split(maxsplit=1)
540
+ command = cmd_parts[0].lower()
541
+ args = cmd_parts[1] if len(cmd_parts) > 1 else ""
542
+
543
+ if command == "/quit" or command == "/exit" or command == "/q":
544
+ return False
545
+
546
+ elif command == "/help":
547
+ show_help()
548
+
549
+ elif command == "/plan":
550
+ current_mode = AgentMode.PLAN
551
+ console.print("[yellow]Switched to plan mode[/yellow]")
552
+
553
+ elif command == "/tasks":
554
+ current_mode = AgentMode.TASKS
555
+ console.print("[magenta]Switched to tasks mode[/magenta]")
556
+
557
+ elif command == "/code":
558
+ current_mode = AgentMode.CODE
559
+ console.print("[green]Switched to code mode[/green]")
560
+
561
+ elif command == "/mode":
562
+ console.print(f"Current mode: [bold]{current_mode.value}[/bold]")
563
+
564
+ elif command == "/reset":
565
+ session_id = None
566
+ current_spec = None
567
+ console.print("[dim]Session reset[/dim]")
568
+
569
+ elif command == "/spec":
570
+ if current_spec:
571
+ console.print(Panel(Markdown(current_spec), title="Current Spec"))
572
+ else:
573
+ console.print("[dim]No spec available. Use plan mode to create one.[/dim]")
574
+
575
+ elif command == "/save":
576
+ if current_spec:
577
+ # TODO: Save spec via API
578
+ console.print("[yellow]Save not implemented yet[/yellow]")
579
+ else:
580
+ console.print("[dim]No spec to save[/dim]")
581
+
582
+ elif command == "/pr":
583
+ # PR review
584
+ if not args:
585
+ console.print("[yellow]Usage: /pr <pr-url-or-number>[/yellow]")
586
+ console.print("[dim]Example: /pr 123 or /pr https://github.com/org/repo/pull/123[/dim]")
587
+ else:
588
+ console.print(f"[cyan]Reviewing PR: {args}[/cyan]")
589
+ _run_slash_command_task(
590
+ client, renderer, model, max_iterations,
591
+ f"Review this pull request and provide feedback: {args}",
592
+ {"mode": "code"}
593
+ )
594
+
595
+ elif command == "/projectmd":
596
+ # Generate PROJECT.md
597
+ console.print("[cyan]Generating PROJECT.md...[/cyan]")
598
+ _run_slash_command_task(
599
+ client, renderer, model, max_iterations,
600
+ "Analyze this codebase and generate a comprehensive PROJECT.md file that describes the architecture, main components, how to get started, and key design decisions.",
601
+ {"mode": "code"}
602
+ )
603
+
604
+ elif command == "/research":
605
+ # Deep research
606
+ if not args:
607
+ console.print("[yellow]Usage: /research <goal>[/yellow]")
608
+ console.print("[dim]Example: /research How does authentication work in this codebase?[/dim]")
609
+ else:
610
+ console.print(f"[cyan]Researching: {args}[/cyan]")
611
+ _run_slash_command_task(
612
+ client, renderer, model, 50, # More iterations for research
613
+ 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.",
614
+ {"mode": "plan"} # Use plan mode for research
615
+ )
616
+
617
+ elif command == "/status":
618
+ # Show index and PROJECT.md status
619
+ from datetime import datetime
620
+
621
+ console.print("\n[bold cyan]Status[/bold cyan]\n")
622
+
623
+ # Index status
624
+ console.print("[bold]Index Status[/bold]")
625
+ try:
626
+ status = client.index_status(str(Path.cwd()))
627
+ is_indexed = status.get("is_indexed", False)
628
+ console.print(f" Indexed: {'[green]Yes[/green]' if is_indexed else '[yellow]No[/yellow]'}")
629
+
630
+ if is_indexed:
631
+ console.print(f" Files: {status.get('file_count', 0)}")
632
+ console.print(f" Functions: {status.get('function_count', 0)}")
633
+ console.print(f" Classes: {status.get('class_count', 0)}")
634
+ console.print(f" Communities: {status.get('community_count', 0)}")
635
+ if status.get("last_indexed"):
636
+ console.print(f" Last indexed: {status.get('last_indexed')}")
637
+ if status.get("last_commit"):
638
+ console.print(f" Last commit: {status.get('last_commit')}")
639
+ except Exception as e:
640
+ console.print(f" [red]Error fetching index status: {e}[/red]")
641
+
642
+ console.print()
643
+
644
+ # PROJECT.md status
645
+ console.print("[bold]PROJECT.md Status[/bold]")
646
+ projectmd_path = Path.cwd() / "PROJECT.md"
647
+ if projectmd_path.exists():
648
+ stat = projectmd_path.stat()
649
+ modified_time = datetime.fromtimestamp(stat.st_mtime)
650
+ size_kb = stat.st_size / 1024
651
+ console.print(f" Exists: [green]Yes[/green]")
652
+ console.print(f" Path: {projectmd_path}")
653
+ console.print(f" Size: {size_kb:.1f} KB")
654
+ console.print(f" Last modified: {modified_time.strftime('%Y-%m-%d %H:%M:%S')}")
655
+ else:
656
+ console.print(f" Exists: [yellow]No[/yellow]")
657
+ console.print("[dim] Run /projectmd to generate it[/dim]")
658
+
659
+ console.print()
660
+
661
+ else:
662
+ console.print(f"[yellow]Unknown command: {command}[/yellow]")
663
+ console.print("[dim]Type /help for available commands[/dim]")
664
+
665
+ return True
666
+
667
+ # Show welcome message
668
+ from .. import __version__
669
+ import subprocess
670
+
671
+ # Get current working directory
672
+ cwd = Path.cwd()
673
+
674
+ # Get git repo name (if in a git repo)
675
+ git_repo = None
676
+ try:
677
+ result = subprocess.run(
678
+ ["git", "rev-parse", "--show-toplevel"],
679
+ capture_output=True, text=True, cwd=cwd
680
+ )
681
+ if result.returncode == 0:
682
+ git_repo = Path(result.stdout.strip()).name
683
+ except Exception:
684
+ pass
685
+
686
+ console.print()
687
+ console.print(f"[bold cyan]EmDash Agent[/bold cyan] [dim]v{__version__}[/dim]")
688
+ console.print(f"Mode: [bold]{current_mode.value}[/bold] | Model: [dim]{model or 'default'}[/dim]")
689
+ if git_repo:
690
+ console.print(f"Repo: [bold green]{git_repo}[/bold green] | Path: [dim]{cwd}[/dim]")
691
+ else:
692
+ console.print(f"Path: [dim]{cwd}[/dim]")
693
+ console.print("Type your task or [cyan]/help[/cyan] for commands. Use Ctrl+C to exit.")
694
+ console.print()
695
+
696
+ while True:
697
+ try:
698
+ # Get user input
699
+ user_input = session.prompt(get_prompt()).strip()
700
+
701
+ if not user_input:
702
+ continue
703
+
704
+ # Handle slash commands
705
+ if user_input.startswith("/"):
706
+ if not handle_slash_command(user_input):
707
+ break
708
+ continue
709
+
710
+ # Handle quit shortcuts
711
+ if user_input.lower() in ("quit", "exit", "q"):
712
+ break
713
+
714
+ # Build options with current mode
715
+ request_options = {
716
+ **options,
717
+ "mode": current_mode.value,
718
+ }
719
+
720
+ # Run agent with current mode
721
+ try:
722
+ if session_id:
723
+ stream = client.agent_continue_stream(session_id, user_input)
724
+ else:
725
+ stream = client.agent_chat_stream(
726
+ message=user_input,
727
+ model=model,
728
+ max_iterations=max_iterations,
729
+ options=request_options,
730
+ )
731
+
732
+ # Render the stream and capture any spec output
733
+ result = renderer.render_stream(stream)
734
+
735
+ # Check if we got a session ID back
736
+ if result and result.get("session_id"):
737
+ session_id = result["session_id"]
738
+
739
+ # Check for spec output
740
+ if result and result.get("spec"):
741
+ current_spec = result["spec"]
742
+
743
+ # Handle clarification with options (interactive selection)
744
+ clarification = result.get("clarification")
745
+ if clarification and clarification.get("options") and session_id:
746
+ response = _get_clarification_response(clarification)
747
+ if response:
748
+ # Continue session with user's choice
749
+ stream = client.agent_continue_stream(session_id, response)
750
+ result = renderer.render_stream(stream)
751
+
752
+ # Update mode if user chose tasks or code
753
+ if "tasks" in response.lower():
754
+ current_mode = AgentMode.TASKS
755
+ elif "code" in response.lower():
756
+ current_mode = AgentMode.CODE
757
+
758
+ # Handle plan mode completion (show approval menu)
759
+ # In plan mode, show menu after any substantial response
760
+ content = result.get("content", "")
761
+ should_show_plan_menu = (
762
+ current_mode == AgentMode.PLAN and
763
+ session_id and
764
+ len(content) > 100 # Has substantial content
765
+ )
766
+ if should_show_plan_menu:
767
+ choice, feedback = _show_spec_approval_menu()
768
+
769
+ if choice == "tasks":
770
+ current_mode = AgentMode.TASKS
771
+ # Include the spec content explicitly in the message
772
+ tasks_prompt = f"""Generate implementation tasks from this approved specification.
773
+
774
+ ## Approved Specification
775
+
776
+ {content}
777
+
778
+ ## Your Task
779
+
780
+ Use your tools to explore the codebase and create implementation tasks:
781
+
782
+ 1. **Explore**: Use semantic_search and code graph tools to find:
783
+ - Existing related code to modify
784
+ - Patterns and conventions used in the codebase
785
+ - Files that will need changes
786
+
787
+ 2. **Generate Tasks**: Create detailed tasks that include:
788
+ - Task ID (T1, T2, T3...)
789
+ - Description of what to implement
790
+ - Specific files to modify (based on your exploration)
791
+ - Dependencies on other tasks
792
+ - Complexity estimate (S/M/L)
793
+ - Acceptance criteria
794
+
795
+ 3. **Order**: Arrange tasks in implementation order, starting with foundational changes.
796
+
797
+ Output a comprehensive task list that a developer can follow step-by-step."""
798
+ stream = client.agent_continue_stream(
799
+ session_id,
800
+ tasks_prompt
801
+ )
802
+ result = renderer.render_stream(stream)
803
+ # After generating tasks, show tasks menu
804
+ content = result.get("content", "")
805
+ elif choice == "code":
806
+ current_mode = AgentMode.CODE
807
+ stream = client.agent_continue_stream(
808
+ session_id,
809
+ "Start implementing the approved spec."
810
+ )
811
+ renderer.render_stream(stream)
812
+ elif choice == "refine":
813
+ stream = client.agent_continue_stream(
814
+ session_id,
815
+ f"Please update the spec based on this feedback: {feedback}"
816
+ )
817
+ renderer.render_stream(stream)
818
+ elif choice == "abort":
819
+ console.print("[dim]Spec discarded[/dim]")
820
+ session_id = None
821
+ current_spec = None
822
+
823
+ # Handle tasks mode completion (show tasks approval menu)
824
+ should_show_tasks_menu = (
825
+ current_mode == AgentMode.TASKS and
826
+ session_id and
827
+ len(content) > 100 # Has substantial content
828
+ )
829
+ if should_show_tasks_menu:
830
+ choice, feedback = _show_tasks_approval_menu()
831
+
832
+ if choice == "code":
833
+ current_mode = AgentMode.CODE
834
+ stream = client.agent_continue_stream(
835
+ session_id,
836
+ "Start implementing the first task from the task list."
837
+ )
838
+ renderer.render_stream(stream)
839
+ elif choice == "refine":
840
+ stream = client.agent_continue_stream(
841
+ session_id,
842
+ f"Please update the tasks based on this feedback: {feedback}"
843
+ )
844
+ renderer.render_stream(stream)
845
+ elif choice == "export":
846
+ console.print("[dim]Exporting tasks...[/dim]")
847
+ # TODO: Implement export functionality
848
+ console.print("[yellow]Export not implemented yet[/yellow]")
849
+ elif choice == "abort":
850
+ console.print("[dim]Tasks discarded[/dim]")
851
+ session_id = None
852
+
853
+ console.print()
854
+
855
+ except Exception as e:
856
+ console.print(f"[red]Error: {e}[/red]")
857
+
858
+ except KeyboardInterrupt:
859
+ console.print("\n[dim]Interrupted[/dim]")
860
+ break
861
+ except EOFError:
862
+ break
863
+
864
+
865
+ @agent.command("sessions")
866
+ def list_sessions():
867
+ """List active agent sessions."""
868
+ server = get_server_manager()
869
+ base_url = server.get_server_url()
870
+
871
+ client = EmdashClient(base_url)
872
+ sessions = client.list_sessions()
873
+
874
+ if not sessions:
875
+ console.print("[dim]No active sessions[/dim]")
876
+ return
877
+
878
+ for s in sessions:
879
+ console.print(
880
+ f" {s['session_id'][:8]}... "
881
+ f"[dim]({s.get('model', 'unknown')}, "
882
+ f"{s.get('message_count', 0)} messages)[/dim]"
883
+ )