emdash-cli 0.1.25__py3-none-any.whl → 0.1.35__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.
@@ -35,7 +35,9 @@ SLASH_COMMANDS = {
35
35
  "/research [goal]": "Deep research on a topic",
36
36
  # Status commands
37
37
  "/status": "Show index and PROJECT.md status",
38
+ "/agents": "List, create, or show agents (e.g., /agents create my-agent)",
38
39
  # Session management
40
+ "/session": "Save, load, or list sessions (e.g., /session save my-task)",
39
41
  "/spec": "Show current specification",
40
42
  "/reset": "Reset session state",
41
43
  "/save": "Save current spec to disk",
@@ -373,6 +375,121 @@ def _show_plan_approval_menu() -> tuple[str, str]:
373
375
  return choice, feedback
374
376
 
375
377
 
378
+ def _show_plan_mode_approval_menu() -> tuple[str, str]:
379
+ """Show plan mode entry approval menu.
380
+
381
+ Returns:
382
+ Tuple of (choice, feedback) where feedback is only set for 'reject'
383
+ """
384
+ from prompt_toolkit import Application
385
+ from prompt_toolkit.key_binding import KeyBindings
386
+ from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
387
+ from prompt_toolkit.styles import Style
388
+
389
+ options = [
390
+ ("approve", "Enter plan mode and explore"),
391
+ ("reject", "Skip planning, proceed directly"),
392
+ ]
393
+
394
+ selected_index = [0]
395
+ result = [None]
396
+
397
+ kb = KeyBindings()
398
+
399
+ @kb.add("up")
400
+ @kb.add("k")
401
+ def move_up(event):
402
+ selected_index[0] = (selected_index[0] - 1) % len(options)
403
+
404
+ @kb.add("down")
405
+ @kb.add("j")
406
+ def move_down(event):
407
+ selected_index[0] = (selected_index[0] + 1) % len(options)
408
+
409
+ @kb.add("enter")
410
+ def select(event):
411
+ result[0] = options[selected_index[0]][0]
412
+ event.app.exit()
413
+
414
+ @kb.add("1")
415
+ @kb.add("y")
416
+ def select_approve(event):
417
+ result[0] = "approve"
418
+ event.app.exit()
419
+
420
+ @kb.add("2")
421
+ @kb.add("n")
422
+ def select_reject(event):
423
+ result[0] = "reject"
424
+ event.app.exit()
425
+
426
+ @kb.add("c-c")
427
+ @kb.add("q")
428
+ @kb.add("escape")
429
+ def cancel(event):
430
+ result[0] = "reject"
431
+ event.app.exit()
432
+
433
+ def get_formatted_options():
434
+ lines = [("class:title", "Enter plan mode?\n\n")]
435
+ for i, (key, desc) in enumerate(options):
436
+ if i == selected_index[0]:
437
+ lines.append(("class:selected", f" ❯ {key:8} "))
438
+ lines.append(("class:selected-desc", f"- {desc}\n"))
439
+ else:
440
+ lines.append(("class:option", f" {key:8} "))
441
+ lines.append(("class:desc", f"- {desc}\n"))
442
+ lines.append(("class:hint", "\n↑/↓ to move, Enter to select, y/n for quick select"))
443
+ return lines
444
+
445
+ style = Style.from_dict({
446
+ "title": "#ffcc00 bold",
447
+ "selected": "#00cc66 bold",
448
+ "selected-desc": "#00cc66",
449
+ "option": "#888888",
450
+ "desc": "#666666",
451
+ "hint": "#444444 italic",
452
+ })
453
+
454
+ layout = Layout(
455
+ HSplit([
456
+ Window(
457
+ FormattedTextControl(get_formatted_options),
458
+ height=6,
459
+ ),
460
+ ])
461
+ )
462
+
463
+ app = Application(
464
+ layout=layout,
465
+ key_bindings=kb,
466
+ style=style,
467
+ full_screen=False,
468
+ )
469
+
470
+ console.print()
471
+
472
+ try:
473
+ app.run()
474
+ except (KeyboardInterrupt, EOFError):
475
+ result[0] = "reject"
476
+
477
+ choice = result[0] or "reject"
478
+
479
+ feedback = ""
480
+ if choice == "reject":
481
+ from prompt_toolkit import PromptSession
482
+ console.print()
483
+ console.print("[dim]Reason for skipping plan mode (optional):[/dim]")
484
+ try:
485
+ session = PromptSession()
486
+ feedback = session.prompt("feedback > ").strip()
487
+ except (KeyboardInterrupt, EOFError):
488
+ return "reject", ""
489
+
490
+ return choice, feedback
491
+
492
+
376
493
  def _render_with_interrupt(renderer: SSERenderer, stream) -> dict:
377
494
  """Render stream with ESC key interrupt support.
378
495
 
@@ -465,12 +582,17 @@ def _run_interactive(
465
582
  current_mode = AgentMode(options.get("mode", "code"))
466
583
  session_id = None
467
584
  current_spec = None
585
+ # Attached images for next message
586
+ attached_images: list[dict] = []
587
+ # Loaded messages from saved session (for restoration)
588
+ loaded_messages: list[dict] = []
468
589
 
469
590
  # Style for prompt
470
591
  PROMPT_STYLE = Style.from_dict({
471
592
  "prompt.mode.plan": "#ffcc00 bold",
472
593
  "prompt.mode.code": "#00cc66 bold",
473
594
  "prompt.prefix": "#888888",
595
+ "prompt.image": "#00ccff",
474
596
  "completion-menu": "bg:#1a1a2e #ffffff",
475
597
  "completion-menu.completion": "bg:#1a1a2e #ffffff",
476
598
  "completion-menu.completion.current": "bg:#4a4a6e #ffffff bold",
@@ -517,6 +639,63 @@ def _run_interactive(
517
639
  """Insert a newline character with Alt+Enter or Ctrl+J."""
518
640
  event.current_buffer.insert_text("\n")
519
641
 
642
+ @kb.add("c-v") # Ctrl+V to paste (check for images)
643
+ def paste_with_image_check(event):
644
+ """Paste text or attach image from clipboard."""
645
+ nonlocal attached_images
646
+ from ..clipboard import get_clipboard_image, get_image_from_path
647
+
648
+ # Try to get image from clipboard
649
+ image_data = get_clipboard_image()
650
+ if image_data:
651
+ base64_data, img_format = image_data
652
+ attached_images.append({"data": base64_data, "format": img_format})
653
+ # Refresh prompt to show updated image list
654
+ event.app.invalidate()
655
+ return
656
+
657
+ # Check if clipboard contains an image file path
658
+ clipboard_data = event.app.clipboard.get_data()
659
+ if clipboard_data and clipboard_data.text:
660
+ text = clipboard_data.text.strip()
661
+ # Remove escape characters from dragged paths (e.g., "path\ with\ spaces")
662
+ clean_path = text.replace("\\ ", " ")
663
+ # Check if it looks like an image file path
664
+ if clean_path.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')):
665
+ image_data = get_image_from_path(clean_path)
666
+ if image_data:
667
+ base64_data, img_format = image_data
668
+ attached_images.append({"data": base64_data, "format": img_format})
669
+ event.app.invalidate()
670
+ return
671
+
672
+ # No image, do normal paste
673
+ event.current_buffer.paste_clipboard_data(clipboard_data)
674
+
675
+ def check_for_image_path(buff):
676
+ """Check if buffer contains an image path and attach it."""
677
+ nonlocal attached_images
678
+ text = buff.text.strip()
679
+ if not text:
680
+ return
681
+ # Clean escaped spaces from dragged paths
682
+ clean_text = text.replace("\\ ", " ")
683
+ if clean_text.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')):
684
+ from ..clipboard import get_image_from_path
685
+ from prompt_toolkit.application import get_app
686
+ image_data = get_image_from_path(clean_text)
687
+ if image_data:
688
+ base64_data, img_format = image_data
689
+ attached_images.append({"data": base64_data, "format": img_format})
690
+ # Clear the buffer
691
+ buff.text = ""
692
+ buff.cursor_position = 0
693
+ # Refresh prompt to show image indicator
694
+ try:
695
+ get_app().invalidate()
696
+ except Exception:
697
+ pass
698
+
520
699
  session = PromptSession(
521
700
  history=history,
522
701
  completer=SlashCommandCompleter(),
@@ -527,15 +706,19 @@ def _run_interactive(
527
706
  key_bindings=kb,
528
707
  )
529
708
 
709
+ # Watch for image paths being pasted/dropped
710
+ session.default_buffer.on_text_changed += check_for_image_path
711
+
530
712
  def get_prompt():
531
- """Get formatted prompt based on current mode."""
532
- mode_colors = {
533
- AgentMode.PLAN: "class:prompt.mode.plan",
534
- AgentMode.CODE: "class:prompt.mode.code",
535
- }
536
- mode_name = current_mode.value
537
- color_class = mode_colors.get(current_mode, "class:prompt.mode.code")
538
- return [(color_class, f"[{mode_name}]"), ("", " "), ("class:prompt.prefix", "> ")]
713
+ """Get formatted prompt."""
714
+ nonlocal attached_images
715
+ parts = []
716
+ # Show attached images above prompt
717
+ if attached_images:
718
+ image_tags = " ".join(f"[Image #{i+1}]" for i in range(len(attached_images)))
719
+ parts.append(("class:prompt.image", f" {image_tags}\n"))
720
+ parts.append(("class:prompt.prefix", "> "))
721
+ return parts
539
722
 
540
723
  def show_help():
541
724
  """Show available commands."""
@@ -564,11 +747,21 @@ def _run_interactive(
564
747
 
565
748
  elif command == "/plan":
566
749
  current_mode = AgentMode.PLAN
567
- console.print("[yellow]Switched to plan mode[/yellow]")
750
+ # Reset session so next chat creates a new session with plan mode
751
+ if session_id:
752
+ session_id = None
753
+ console.print("[bold green]✓ Plan mode activated[/bold green] [dim](session reset)[/dim]")
754
+ else:
755
+ console.print("[bold green]✓ Plan mode activated[/bold green]")
568
756
 
569
757
  elif command == "/code":
570
758
  current_mode = AgentMode.CODE
571
- console.print("[green]Switched to code mode[/green]")
759
+ # Reset session so next chat creates a new session with code mode
760
+ if session_id:
761
+ session_id = None
762
+ console.print("[green]Switched to code mode (session reset)[/green]")
763
+ else:
764
+ console.print("[green]Switched to code mode[/green]")
572
765
 
573
766
  elif command == "/mode":
574
767
  console.print(f"Current mode: [bold]{current_mode.value}[/bold]")
@@ -670,6 +863,256 @@ def _run_interactive(
670
863
 
671
864
  console.print()
672
865
 
866
+ elif command == "/agents":
867
+ # Agents management: list, create, show
868
+ from emdash_core.agent.toolkits import list_agent_types, get_custom_agent
869
+
870
+ # Parse subcommand
871
+ subparts = args.split(maxsplit=1) if args else []
872
+ subcommand = subparts[0].lower() if subparts else "list"
873
+ subargs = subparts[1] if len(subparts) > 1 else ""
874
+
875
+ if subcommand in ("list", ""):
876
+ # List all agents
877
+ console.print("\n[bold cyan]Available Agents[/bold cyan]\n")
878
+
879
+ all_agents = list_agent_types(Path.cwd())
880
+ builtin = ["Explore", "Plan"]
881
+
882
+ console.print("[bold]Built-in Agents[/bold]")
883
+ for agent_name in builtin:
884
+ if agent_name == "Explore":
885
+ console.print(" [green]Explore[/green] - Fast codebase exploration (read-only)")
886
+ elif agent_name == "Plan":
887
+ console.print(" [green]Plan[/green] - Design implementation plans")
888
+
889
+ # Custom agents
890
+ custom = [a for a in all_agents if a not in builtin]
891
+ if custom:
892
+ console.print("\n[bold]Custom Agents[/bold] [dim](.emdash/agents/)[/dim]")
893
+ for name in custom:
894
+ agent = get_custom_agent(name, Path.cwd())
895
+ desc = agent.description if agent else ""
896
+ if desc:
897
+ console.print(f" [cyan]{name}[/cyan] - {desc}")
898
+ else:
899
+ console.print(f" [cyan]{name}[/cyan]")
900
+ else:
901
+ console.print("\n[dim]No custom agents found.[/dim]")
902
+ console.print("[dim]Create with: /agents create <name>[/dim]")
903
+
904
+ console.print()
905
+
906
+ elif subcommand == "create":
907
+ # Create a new custom agent
908
+ if not subargs:
909
+ console.print("[yellow]Usage: /agents create <name>[/yellow]")
910
+ console.print("[dim]Example: /agents create code-reviewer[/dim]")
911
+ else:
912
+ agent_name = subargs.strip().lower().replace(" ", "-")
913
+ agents_dir = Path.cwd() / ".emdash" / "agents"
914
+ agent_file = agents_dir / f"{agent_name}.md"
915
+
916
+ if agent_file.exists():
917
+ console.print(f"[yellow]Agent '{agent_name}' already exists[/yellow]")
918
+ console.print(f"[dim]Edit: {agent_file}[/dim]")
919
+ else:
920
+ # Create directory if needed
921
+ agents_dir.mkdir(parents=True, exist_ok=True)
922
+
923
+ # Create template
924
+ template = f'''---
925
+ description: Custom agent for specific tasks
926
+ tools: [grep, glob, read_file, semantic_search]
927
+ ---
928
+
929
+ # System Prompt
930
+
931
+ You are a specialized assistant for {agent_name.replace("-", " ")} tasks.
932
+
933
+ ## Your Mission
934
+
935
+ Describe what this agent should accomplish:
936
+ - Task 1
937
+ - Task 2
938
+ - Task 3
939
+
940
+ ## Approach
941
+
942
+ 1. **Step One**
943
+ - Details about the first step
944
+
945
+ 2. **Step Two**
946
+ - Details about the second step
947
+
948
+ ## Output Format
949
+
950
+ Describe how the agent should format its responses.
951
+
952
+ # Examples
953
+
954
+ ## Example 1
955
+ User: Example user request
956
+ Agent: Example agent response describing what it would do
957
+ '''
958
+ agent_file.write_text(template)
959
+ console.print(f"[green]Created agent: {agent_name}[/green]")
960
+ console.print(f"[dim]Edit to customize: {agent_file}[/dim]")
961
+ console.print(f"\n[dim]Spawn with Task tool: subagent_type='{agent_name}'[/dim]")
962
+
963
+ elif subcommand == "show":
964
+ # Show details of an agent
965
+ if not subargs:
966
+ console.print("[yellow]Usage: /agents show <name>[/yellow]")
967
+ else:
968
+ agent_name = subargs.strip()
969
+ builtin = ["Explore", "Plan"]
970
+
971
+ if agent_name in builtin:
972
+ console.print(f"\n[bold cyan]{agent_name}[/bold cyan] [dim](built-in)[/dim]\n")
973
+ if agent_name == "Explore":
974
+ console.print("Fast codebase exploration agent (read-only)")
975
+ console.print("\n[bold]Tools:[/bold] glob, grep, read_file, list_files, semantic_search")
976
+ elif agent_name == "Plan":
977
+ console.print("Implementation planning agent")
978
+ console.print("\n[bold]Tools:[/bold] glob, grep, read_file, list_files, semantic_search")
979
+ console.print()
980
+ else:
981
+ agent = get_custom_agent(agent_name, Path.cwd())
982
+ if agent:
983
+ console.print(f"\n[bold cyan]{agent.name}[/bold cyan] [dim](custom)[/dim]\n")
984
+ if agent.description:
985
+ console.print(f"[bold]Description:[/bold] {agent.description}")
986
+ if agent.tools:
987
+ console.print(f"[bold]Tools:[/bold] {', '.join(agent.tools)}")
988
+ if agent.file_path:
989
+ console.print(f"[bold]File:[/bold] {agent.file_path}")
990
+ if agent.system_prompt:
991
+ console.print(f"\n[bold]System Prompt:[/bold]")
992
+ # Show first 500 chars of system prompt
993
+ preview = agent.system_prompt[:500]
994
+ if len(agent.system_prompt) > 500:
995
+ preview += "..."
996
+ console.print(Panel(preview, border_style="dim"))
997
+ console.print()
998
+ else:
999
+ console.print(f"[yellow]Agent '{agent_name}' not found[/yellow]")
1000
+ console.print("[dim]Use /agents to list available agents[/dim]")
1001
+
1002
+ else:
1003
+ console.print(f"[yellow]Unknown subcommand: {subcommand}[/yellow]")
1004
+ console.print("[dim]Usage: /agents [list|create|show] [name][/dim]")
1005
+
1006
+ elif command == "/session":
1007
+ # Session management: list, save, load, delete, clear
1008
+ from ..session_store import SessionStore
1009
+
1010
+ store = SessionStore(Path.cwd())
1011
+
1012
+ # Parse subcommand
1013
+ subparts = args.split(maxsplit=1) if args else []
1014
+ subcommand = subparts[0].lower() if subparts else "list"
1015
+ subargs = subparts[1].strip() if len(subparts) > 1 else ""
1016
+
1017
+ if subcommand == "list" or subcommand == "":
1018
+ # List all sessions
1019
+ sessions = store.list_sessions()
1020
+ if sessions:
1021
+ console.print("\n[bold cyan]Saved Sessions[/bold cyan]\n")
1022
+ for s in sessions:
1023
+ mode_color = "green" if s.mode == "code" else "yellow"
1024
+ active_marker = " [bold green]*[/bold green]" if store.get_active_session() == s.name else ""
1025
+ console.print(f" [cyan]{s.name}[/cyan]{active_marker} [{mode_color}]{s.mode}[/{mode_color}]")
1026
+ console.print(f" [dim]{s.message_count} messages | {s.updated_at[:10]}[/dim]")
1027
+ if s.summary:
1028
+ summary = s.summary[:60] + "..." if len(s.summary) > 60 else s.summary
1029
+ console.print(f" [dim]{summary}[/dim]")
1030
+ console.print()
1031
+ else:
1032
+ console.print("\n[dim]No saved sessions.[/dim]")
1033
+ console.print("[dim]Save with: /session save <name>[/dim]\n")
1034
+
1035
+ elif subcommand == "save":
1036
+ if not subargs:
1037
+ console.print("[yellow]Usage: /session save <name>[/yellow]")
1038
+ console.print("[dim]Example: /session save auth-feature[/dim]")
1039
+ else:
1040
+ # Get current messages from the API session
1041
+ if session_id:
1042
+ try:
1043
+ # Export messages from server
1044
+ export_resp = client.get(f"/api/agent/chat/{session_id}/export")
1045
+ if export_resp.status_code == 200:
1046
+ data = export_resp.json()
1047
+ messages = data.get("messages", [])
1048
+ else:
1049
+ messages = []
1050
+ except Exception:
1051
+ messages = []
1052
+ else:
1053
+ messages = []
1054
+
1055
+ success, msg = store.save_session(
1056
+ name=subargs,
1057
+ messages=messages,
1058
+ mode=current_mode.value,
1059
+ spec=current_spec,
1060
+ model=model,
1061
+ )
1062
+ if success:
1063
+ store.set_active_session(subargs)
1064
+ console.print(f"[green]{msg}[/green]")
1065
+ else:
1066
+ console.print(f"[yellow]{msg}[/yellow]")
1067
+
1068
+ elif subcommand == "load":
1069
+ if not subargs:
1070
+ console.print("[yellow]Usage: /session load <name>[/yellow]")
1071
+ else:
1072
+ session_data = store.load_session(subargs)
1073
+ if session_data:
1074
+ # Reset current session
1075
+ session_id = None
1076
+ current_spec = session_data.spec
1077
+ if session_data.mode == "plan":
1078
+ current_mode = AgentMode.PLAN
1079
+ else:
1080
+ current_mode = AgentMode.CODE
1081
+
1082
+ # Store loaded messages for replay
1083
+ nonlocal loaded_messages
1084
+ loaded_messages = session_data.messages
1085
+
1086
+ store.set_active_session(subargs)
1087
+ console.print(f"[green]Loaded session '{subargs}'[/green]")
1088
+ console.print(f"[dim]{len(session_data.messages)} messages restored, mode: {current_mode.value}[/dim]")
1089
+ if current_spec:
1090
+ console.print("[dim]Spec restored[/dim]")
1091
+ else:
1092
+ console.print(f"[yellow]Session '{subargs}' not found[/yellow]")
1093
+
1094
+ elif subcommand == "delete":
1095
+ if not subargs:
1096
+ console.print("[yellow]Usage: /session delete <name>[/yellow]")
1097
+ else:
1098
+ success, msg = store.delete_session(subargs)
1099
+ if success:
1100
+ console.print(f"[green]{msg}[/green]")
1101
+ else:
1102
+ console.print(f"[yellow]{msg}[/yellow]")
1103
+
1104
+ elif subcommand == "clear":
1105
+ # Clear current session state
1106
+ session_id = None
1107
+ current_spec = None
1108
+ loaded_messages = []
1109
+ store.set_active_session(None)
1110
+ console.print("[green]Session cleared[/green]")
1111
+
1112
+ else:
1113
+ console.print(f"[yellow]Unknown subcommand: {subcommand}[/yellow]")
1114
+ console.print("[dim]Usage: /session [list|save|load|delete|clear] [name][/dim]")
1115
+
673
1116
  else:
674
1117
  console.print(f"[yellow]Unknown command: {command}[/yellow]")
675
1118
  console.print("[dim]Type /help for available commands[/dim]")
@@ -695,14 +1138,13 @@ def _run_interactive(
695
1138
  except Exception:
696
1139
  pass
697
1140
 
1141
+ # Welcome banner
698
1142
  console.print()
699
- console.print(f"[bold cyan]EmDash Agent[/bold cyan] [dim]v{__version__}[/dim]")
700
- console.print(f"Mode: [bold]{current_mode.value}[/bold] | Model: [dim]{model or 'default'}[/dim]")
1143
+ console.print(f"[bold cyan]Mendy10 Emdash Code[/bold cyan] [dim]v{__version__}[/dim]")
701
1144
  if git_repo:
702
- console.print(f"Repo: [bold green]{git_repo}[/bold green] | Path: [dim]{cwd}[/dim]")
1145
+ 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'}")
703
1146
  else:
704
- console.print(f"Path: [dim]{cwd}[/dim]")
705
- console.print("Type your task or [cyan]/help[/cyan] for commands. Use Ctrl+C to exit.")
1147
+ console.print(f"[dim]Mode:[/dim] [bold]{current_mode.value}[/bold] [dim]| Model:[/dim] {model or 'default'}")
706
1148
  console.print()
707
1149
 
708
1150
  while True:
@@ -713,6 +1155,16 @@ def _run_interactive(
713
1155
  if not user_input:
714
1156
  continue
715
1157
 
1158
+ # Check if input is an image file path (dragged file)
1159
+ clean_input = user_input.replace("\\ ", " ")
1160
+ if clean_input.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')):
1161
+ from ..clipboard import get_image_from_path
1162
+ image_data = get_image_from_path(clean_input)
1163
+ if image_data:
1164
+ base64_data, img_format = image_data
1165
+ attached_images.append({"data": base64_data, "format": img_format})
1166
+ continue # Prompt again for actual message
1167
+
716
1168
  # Handle slash commands
717
1169
  if user_input.startswith("/"):
718
1170
  if not handle_slash_command(user_input):
@@ -731,15 +1183,28 @@ def _run_interactive(
731
1183
 
732
1184
  # Run agent with current mode
733
1185
  try:
1186
+ # Prepare images for API call
1187
+ images_to_send = attached_images if attached_images else None
1188
+
734
1189
  if session_id:
735
- stream = client.agent_continue_stream(session_id, user_input)
1190
+ stream = client.agent_continue_stream(
1191
+ session_id, user_input, images=images_to_send
1192
+ )
736
1193
  else:
1194
+ # Pass loaded_messages from saved session if available
737
1195
  stream = client.agent_chat_stream(
738
1196
  message=user_input,
739
1197
  model=model,
740
1198
  max_iterations=max_iterations,
741
1199
  options=request_options,
1200
+ images=images_to_send,
1201
+ history=loaded_messages if loaded_messages else None,
742
1202
  )
1203
+ # Clear loaded_messages after first use
1204
+ loaded_messages = []
1205
+
1206
+ # Clear attached images after sending
1207
+ attached_images = []
743
1208
 
744
1209
  # Render the stream and capture any spec output
745
1210
  result = _render_with_interrupt(renderer, stream)
@@ -752,22 +1217,56 @@ def _run_interactive(
752
1217
  if result and result.get("spec"):
753
1218
  current_spec = result["spec"]
754
1219
 
755
- # Handle clarification with options (interactive selection)
756
- clarification = result.get("clarification")
757
- if clarification and clarification.get("options") and session_id:
1220
+ # Handle clarifications (may be chained - loop until no more)
1221
+ while True:
1222
+ clarification = result.get("clarification")
1223
+ if not (clarification and session_id):
1224
+ break
1225
+
758
1226
  response = _get_clarification_response(clarification)
759
- if response:
760
- # Continue session with user's choice
761
- stream = client.agent_continue_stream(session_id, response)
1227
+ if not response:
1228
+ break
1229
+
1230
+ # Show the user's selection in the chat
1231
+ console.print()
1232
+ console.print(f"[dim]Selected:[/dim] [bold]{response}[/bold]")
1233
+ console.print()
1234
+
1235
+ # Use dedicated clarification answer endpoint
1236
+ try:
1237
+ stream = client.clarification_answer_stream(session_id, response)
762
1238
  result = _render_with_interrupt(renderer, stream)
763
1239
 
764
1240
  # Update mode if user chose code
765
1241
  if "code" in response.lower():
766
1242
  current_mode = AgentMode.CODE
1243
+ except Exception as e:
1244
+ console.print(f"[red]Error continuing session: {e}[/red]")
1245
+ break
1246
+
1247
+ # Handle plan mode entry request (show approval menu)
1248
+ plan_mode_requested = result.get("plan_mode_requested")
1249
+ if plan_mode_requested is not None and session_id:
1250
+ choice, feedback = _show_plan_mode_approval_menu()
1251
+
1252
+ if choice == "approve":
1253
+ current_mode = AgentMode.PLAN
1254
+ console.print()
1255
+ console.print("[bold green]✓ Plan mode activated[/bold green]")
1256
+ console.print()
1257
+ # Use the planmode approve endpoint
1258
+ stream = client.planmode_approve_stream(session_id)
1259
+ result = _render_with_interrupt(renderer, stream)
1260
+ # After approval, check if there's now a plan submitted
1261
+ if result.get("plan_submitted"):
1262
+ plan_submitted = result.get("plan_submitted")
1263
+ elif choice == "reject":
1264
+ # Use the planmode reject endpoint - stay in code mode
1265
+ stream = client.planmode_reject_stream(session_id, feedback)
1266
+ _render_with_interrupt(renderer, stream)
767
1267
 
768
1268
  # Handle plan mode completion (show approval menu)
769
1269
  # Only show menu when agent explicitly submits a plan via exit_plan tool
770
- content = result.get("content", "")
771
1270
  plan_submitted = result.get("plan_submitted")
772
1271
  should_show_plan_menu = (
773
1272
  current_mode == AgentMode.PLAN and
@@ -779,20 +1278,13 @@ def _run_interactive(
779
1278
 
780
1279
  if choice == "approve":
781
1280
  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
785
- stream = client.agent_continue_stream(
786
- session_id,
787
- "The plan has been approved. Start implementing it now."
788
- )
1281
+ # Use the plan approve endpoint which properly resets mode on server
1282
+ stream = client.plan_approve_stream(session_id)
789
1283
  _render_with_interrupt(renderer, stream)
790
1284
  elif choice == "reject":
791
1285
  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
- )
1286
+ # Use the plan reject endpoint which keeps mode as PLAN on server
1287
+ stream = client.plan_reject_stream(session_id, feedback)
796
1288
  _render_with_interrupt(renderer, stream)
797
1289
  else:
798
1290
  console.print("[dim]Plan rejected[/dim]")