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
@@ -0,0 +1,657 @@
1
+ """Interactive REPL mode for the agent CLI."""
2
+
3
+ import subprocess
4
+ import threading
5
+ from pathlib import Path
6
+
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.markdown import Markdown
10
+
11
+ from .constants import AgentMode, SLASH_COMMANDS
12
+ from .file_utils import expand_file_references, fuzzy_find_files
13
+ from .menus import (
14
+ get_clarification_response,
15
+ show_plan_approval_menu,
16
+ show_plan_mode_approval_menu,
17
+ )
18
+ from .handlers import (
19
+ handle_agents,
20
+ handle_session,
21
+ handle_todos,
22
+ handle_todo_add,
23
+ handle_hooks,
24
+ handle_rules,
25
+ handle_skills,
26
+ handle_mcp,
27
+ handle_auth,
28
+ handle_doctor,
29
+ handle_verify,
30
+ handle_verify_loop,
31
+ handle_setup,
32
+ handle_status,
33
+ handle_pr,
34
+ handle_projectmd,
35
+ handle_research,
36
+ handle_context,
37
+ )
38
+
39
+ console = Console()
40
+
41
+
42
+ def render_with_interrupt(renderer, stream) -> dict:
43
+ """Render stream with ESC key interrupt support.
44
+
45
+ Args:
46
+ renderer: SSE renderer instance
47
+ stream: SSE stream iterator
48
+
49
+ Returns:
50
+ Result dict from renderer, with 'interrupted' flag
51
+ """
52
+ from ...keyboard import KeyListener
53
+
54
+ interrupt_event = threading.Event()
55
+
56
+ def on_escape():
57
+ interrupt_event.set()
58
+
59
+ listener = KeyListener(on_escape)
60
+
61
+ try:
62
+ listener.start()
63
+ result = renderer.render_stream(stream, interrupt_event=interrupt_event)
64
+ return result
65
+ finally:
66
+ listener.stop()
67
+
68
+
69
+ def run_single_task(
70
+ client,
71
+ renderer,
72
+ task: str,
73
+ model: str | None,
74
+ max_iterations: int,
75
+ options: dict,
76
+ ):
77
+ """Run a single agent task."""
78
+ import click
79
+
80
+ try:
81
+ stream = client.agent_chat_stream(
82
+ message=task,
83
+ model=model,
84
+ max_iterations=max_iterations,
85
+ options=options,
86
+ )
87
+ result = render_with_interrupt(renderer, stream)
88
+ if result.get("interrupted"):
89
+ console.print("[dim]Task interrupted. You can continue or start a new task.[/dim]")
90
+ except Exception as e:
91
+ console.print(f"[red]Error: {e}[/red]")
92
+ raise click.Abort()
93
+
94
+
95
+ def run_slash_command_task(
96
+ client,
97
+ renderer,
98
+ model: str | None,
99
+ max_iterations: int,
100
+ task: str,
101
+ options: dict,
102
+ ):
103
+ """Run a task from a slash command."""
104
+ try:
105
+ stream = client.agent_chat_stream(
106
+ message=task,
107
+ model=model,
108
+ max_iterations=max_iterations,
109
+ options=options,
110
+ )
111
+ result = render_with_interrupt(renderer, stream)
112
+ if result.get("interrupted"):
113
+ console.print("[dim]Task interrupted.[/dim]")
114
+ console.print()
115
+ except Exception as e:
116
+ console.print(f"[red]Error: {e}[/red]")
117
+
118
+
119
+ def run_interactive(
120
+ client,
121
+ renderer,
122
+ model: str | None,
123
+ max_iterations: int,
124
+ options: dict,
125
+ ):
126
+ """Run interactive REPL mode with slash commands."""
127
+ from prompt_toolkit import PromptSession
128
+ from prompt_toolkit.history import FileHistory
129
+ from prompt_toolkit.completion import Completer, Completion
130
+ from prompt_toolkit.styles import Style
131
+ from prompt_toolkit.key_binding import KeyBindings
132
+
133
+ # Current mode
134
+ current_mode = AgentMode(options.get("mode", "code"))
135
+ session_id = None
136
+ current_spec = None
137
+ # Attached images for next message
138
+ attached_images: list[dict] = []
139
+ # Loaded messages from saved session (for restoration)
140
+ loaded_messages: list[dict] = []
141
+ # Pending todos to add when session starts
142
+ pending_todos: list[str] = []
143
+
144
+ # Style for prompt
145
+ PROMPT_STYLE = Style.from_dict({
146
+ "prompt.mode.plan": "#ffcc00 bold",
147
+ "prompt.mode.code": "#00cc66 bold",
148
+ "prompt.prefix": "#888888",
149
+ "prompt.image": "#00ccff",
150
+ "completion-menu": "bg:#1a1a2e #ffffff",
151
+ "completion-menu.completion": "bg:#1a1a2e #ffffff",
152
+ "completion-menu.completion.current": "bg:#4a4a6e #ffffff bold",
153
+ "completion-menu.meta.completion": "bg:#1a1a2e #888888",
154
+ "completion-menu.meta.completion.current": "bg:#4a4a6e #aaaaaa",
155
+ "command": "#00ccff bold",
156
+ })
157
+
158
+ class SlashCommandCompleter(Completer):
159
+ """Completer for slash commands and @file references."""
160
+
161
+ def get_completions(self, document, complete_event):
162
+ text = document.text_before_cursor
163
+
164
+ # Handle @file completions
165
+ # Find the last @ in the text
166
+ at_idx = text.rfind('@')
167
+ if at_idx != -1:
168
+ # Get the query after @
169
+ query = text[at_idx + 1:]
170
+ # Only complete if query has at least 1 char and no space after @
171
+ if query and ' ' not in query:
172
+ matches = fuzzy_find_files(query, limit=10)
173
+ cwd = Path.cwd()
174
+ for match in matches:
175
+ try:
176
+ rel_path = match.relative_to(cwd)
177
+ except ValueError:
178
+ rel_path = match
179
+ # Replace from @ onwards
180
+ yield Completion(
181
+ f"@{rel_path}",
182
+ start_position=-(len(query) + 1), # +1 for @
183
+ display=str(rel_path),
184
+ display_meta="file",
185
+ )
186
+ return
187
+
188
+ # Handle slash commands
189
+ if not text.startswith("/"):
190
+ return
191
+ for cmd, description in SLASH_COMMANDS.items():
192
+ # Extract base command (e.g., "/pr" from "/pr [url]")
193
+ base_cmd = cmd.split()[0]
194
+ if base_cmd.startswith(text):
195
+ yield Completion(
196
+ base_cmd,
197
+ start_position=-len(text),
198
+ display=cmd,
199
+ display_meta=description,
200
+ )
201
+
202
+ # Setup history file
203
+ history_file = Path.home() / ".emdash" / "cli_history"
204
+ history_file.parent.mkdir(parents=True, exist_ok=True)
205
+ history = FileHistory(str(history_file))
206
+
207
+ # Key bindings: Enter submits, Alt+Enter inserts newline
208
+ kb = KeyBindings()
209
+
210
+ @kb.add("enter", eager=True)
211
+ def submit_on_enter(event):
212
+ """Submit on Enter."""
213
+ event.current_buffer.validate_and_handle()
214
+
215
+ @kb.add("escape", "enter") # Alt+Enter (Escape then Enter)
216
+ @kb.add("c-j") # Ctrl+J as alternative for newline
217
+ def insert_newline_alt(event):
218
+ """Insert a newline character with Alt+Enter or Ctrl+J."""
219
+ event.current_buffer.insert_text("\n")
220
+
221
+ @kb.add("c-v") # Ctrl+V to paste (check for images)
222
+ def paste_with_image_check(event):
223
+ """Paste text or attach image from clipboard."""
224
+ nonlocal attached_images
225
+ from ...clipboard import get_clipboard_image, get_image_from_path
226
+
227
+ # Try to get image from clipboard
228
+ image_data = get_clipboard_image()
229
+ if image_data:
230
+ base64_data, img_format = image_data
231
+ attached_images.append({"data": base64_data, "format": img_format})
232
+ # Refresh prompt to show updated image list
233
+ event.app.invalidate()
234
+ return
235
+
236
+ # Check if clipboard contains an image file path
237
+ clipboard_data = event.app.clipboard.get_data()
238
+ if clipboard_data and clipboard_data.text:
239
+ text = clipboard_data.text.strip()
240
+ # Remove escape characters from dragged paths (e.g., "path\ with\ spaces")
241
+ clean_path = text.replace("\\ ", " ")
242
+ # Check if it looks like an image file path
243
+ if clean_path.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')):
244
+ image_data = get_image_from_path(clean_path)
245
+ if image_data:
246
+ base64_data, img_format = image_data
247
+ attached_images.append({"data": base64_data, "format": img_format})
248
+ event.app.invalidate()
249
+ return
250
+
251
+ # No image, do normal paste
252
+ event.current_buffer.paste_clipboard_data(clipboard_data)
253
+
254
+ def check_for_image_path(buff):
255
+ """Check if buffer contains an image path and attach it."""
256
+ nonlocal attached_images
257
+ text = buff.text.strip()
258
+ if not text:
259
+ return
260
+ # Clean escaped spaces from dragged paths
261
+ clean_text = text.replace("\\ ", " ")
262
+ if clean_text.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')):
263
+ from ...clipboard import get_image_from_path
264
+ from prompt_toolkit.application import get_app
265
+ image_data = get_image_from_path(clean_text)
266
+ if image_data:
267
+ base64_data, img_format = image_data
268
+ attached_images.append({"data": base64_data, "format": img_format})
269
+ # Clear the buffer
270
+ buff.text = ""
271
+ buff.cursor_position = 0
272
+ # Refresh prompt to show image indicator
273
+ try:
274
+ get_app().invalidate()
275
+ except Exception:
276
+ pass
277
+
278
+ session = PromptSession(
279
+ history=history,
280
+ completer=SlashCommandCompleter(),
281
+ style=PROMPT_STYLE,
282
+ complete_while_typing=True,
283
+ multiline=True,
284
+ prompt_continuation="... ",
285
+ key_bindings=kb,
286
+ )
287
+
288
+ # Watch for image paths being pasted/dropped
289
+ session.default_buffer.on_text_changed += check_for_image_path
290
+
291
+ def get_prompt():
292
+ """Get formatted prompt."""
293
+ nonlocal attached_images
294
+ parts = []
295
+ # Show attached images above prompt
296
+ if attached_images:
297
+ image_tags = " ".join(f"[Image #{i+1}]" for i in range(len(attached_images)))
298
+ parts.append(("class:prompt.image", f" {image_tags}\n"))
299
+ parts.append(("class:prompt.prefix", "> "))
300
+ return parts
301
+
302
+ def show_help():
303
+ """Show available commands."""
304
+ console.print()
305
+ console.print("[bold cyan]Available Commands[/bold cyan]")
306
+ console.print()
307
+ for cmd, desc in SLASH_COMMANDS.items():
308
+ console.print(f" [cyan]{cmd:12}[/cyan] {desc}")
309
+ console.print()
310
+ console.print("[dim]Type your task or question to interact with the agent.[/dim]")
311
+ console.print()
312
+
313
+ def handle_slash_command(cmd: str) -> bool:
314
+ """Handle a slash command. Returns True if should continue, False to exit."""
315
+ nonlocal current_mode, session_id, current_spec, pending_todos
316
+
317
+ cmd_parts = cmd.strip().split(maxsplit=1)
318
+ command = cmd_parts[0].lower()
319
+ args = cmd_parts[1] if len(cmd_parts) > 1 else ""
320
+
321
+ if command == "/quit" or command == "/exit" or command == "/q":
322
+ return False
323
+
324
+ elif command == "/help":
325
+ show_help()
326
+
327
+ elif command == "/plan":
328
+ current_mode = AgentMode.PLAN
329
+ # Reset session so next chat creates a new session with plan mode
330
+ if session_id:
331
+ session_id = None
332
+ console.print("[bold green]✓ Plan mode activated[/bold green] [dim](session reset)[/dim]")
333
+ else:
334
+ console.print("[bold green]✓ Plan mode activated[/bold green]")
335
+
336
+ elif command == "/code":
337
+ current_mode = AgentMode.CODE
338
+ # Reset session so next chat creates a new session with code mode
339
+ if session_id:
340
+ session_id = None
341
+ console.print("[green]Switched to code mode (session reset)[/green]")
342
+ else:
343
+ console.print("[green]Switched to code mode[/green]")
344
+
345
+ elif command == "/mode":
346
+ console.print(f"Current mode: [bold]{current_mode.value}[/bold]")
347
+
348
+ elif command == "/reset":
349
+ session_id = None
350
+ current_spec = None
351
+ console.print("[dim]Session reset[/dim]")
352
+
353
+ elif command == "/spec":
354
+ if current_spec:
355
+ console.print(Panel(Markdown(current_spec), title="Current Spec"))
356
+ else:
357
+ console.print("[dim]No spec available. Use plan mode to create one.[/dim]")
358
+
359
+ elif command == "/pr":
360
+ handle_pr(args, run_slash_command_task, client, renderer, model, max_iterations)
361
+
362
+ elif command == "/projectmd":
363
+ handle_projectmd(run_slash_command_task, client, renderer, model, max_iterations)
364
+
365
+ elif command == "/research":
366
+ handle_research(args, run_slash_command_task, client, renderer, model)
367
+
368
+ elif command == "/status":
369
+ handle_status(client)
370
+
371
+ elif command == "/agents":
372
+ handle_agents(args, client, renderer, model, max_iterations, render_with_interrupt)
373
+
374
+ elif command == "/todos":
375
+ handle_todos(args, client, session_id, pending_todos)
376
+
377
+ elif command == "/todo-add":
378
+ handle_todo_add(args, client, session_id, pending_todos)
379
+
380
+ elif command == "/session":
381
+ # Use list wrappers to allow mutation
382
+ session_id_ref = [session_id]
383
+ current_spec_ref = [current_spec]
384
+ current_mode_ref = [current_mode]
385
+ loaded_messages_ref = [loaded_messages]
386
+
387
+ handle_session(
388
+ args, client, model,
389
+ session_id_ref, current_spec_ref, current_mode_ref, loaded_messages_ref
390
+ )
391
+
392
+ # Update local variables from refs
393
+ session_id = session_id_ref[0]
394
+ current_spec = current_spec_ref[0]
395
+ current_mode = current_mode_ref[0]
396
+ loaded_messages[:] = loaded_messages_ref[0]
397
+
398
+ elif command == "/hooks":
399
+ handle_hooks(args)
400
+
401
+ elif command == "/rules":
402
+ handle_rules(args, client, renderer, model, max_iterations, render_with_interrupt)
403
+
404
+ elif command == "/skills":
405
+ handle_skills(args, client, renderer, model, max_iterations, render_with_interrupt)
406
+
407
+ elif command == "/context":
408
+ handle_context(renderer)
409
+
410
+ elif command == "/mcp":
411
+ handle_mcp(args)
412
+
413
+ elif command == "/auth":
414
+ handle_auth(args)
415
+
416
+ elif command == "/doctor":
417
+ handle_doctor(args)
418
+
419
+ elif command == "/verify":
420
+ handle_verify(args, client, renderer, model, max_iterations, render_with_interrupt)
421
+
422
+ elif command == "/verify-loop":
423
+ if not args:
424
+ console.print("[yellow]Usage: /verify-loop <task description>[/yellow]")
425
+ console.print("[dim]Example: /verify-loop fix the failing tests[/dim]")
426
+ return True
427
+
428
+ # Create a task runner function that uses current client/renderer
429
+ def run_task(task_message: str):
430
+ nonlocal session_id # session_id is declared nonlocal in handle_slash_command
431
+ if session_id:
432
+ stream = client.agent_continue_stream(session_id, task_message)
433
+ else:
434
+ stream = client.agent_chat_stream(
435
+ message=task_message,
436
+ model=model,
437
+ max_iterations=max_iterations,
438
+ options={**options, "mode": current_mode.value},
439
+ )
440
+ result = render_with_interrupt(renderer, stream)
441
+ if result and result.get("session_id"):
442
+ session_id = result["session_id"]
443
+
444
+ handle_verify_loop(args, run_task)
445
+ return True
446
+
447
+ elif command == "/setup":
448
+ handle_setup(args, client, renderer, model)
449
+ return True
450
+
451
+ else:
452
+ console.print(f"[yellow]Unknown command: {command}[/yellow]")
453
+ console.print("[dim]Type /help for available commands[/dim]")
454
+
455
+ return True
456
+
457
+ # Show welcome message
458
+ from ... import __version__
459
+
460
+ # Get current working directory
461
+ cwd = Path.cwd()
462
+
463
+ # Get git repo name (if in a git repo)
464
+ git_repo = None
465
+ try:
466
+ result = subprocess.run(
467
+ ["git", "rev-parse", "--show-toplevel"],
468
+ capture_output=True, text=True, cwd=cwd
469
+ )
470
+ if result.returncode == 0:
471
+ git_repo = Path(result.stdout.strip()).name
472
+ except Exception:
473
+ pass
474
+
475
+ # Welcome banner
476
+ console.print()
477
+ console.print(f"[bold cyan] Emdash Code[/bold cyan] [dim]v{__version__}[/dim]")
478
+ # Get display model name
479
+ if model:
480
+ display_model = model
481
+ else:
482
+ from emdash_core.agent.providers.factory import DEFAULT_MODEL
483
+ display_model = DEFAULT_MODEL
484
+ if git_repo:
485
+ console.print(f"[dim]Repo:[/dim] [bold green]{git_repo}[/bold green] [dim]| Mode:[/dim] [bold]{current_mode.value}[/bold] [dim]| Model:[/dim] {display_model}")
486
+ else:
487
+ console.print(f"[dim]Mode:[/dim] [bold]{current_mode.value}[/bold] [dim]| Model:[/dim] {display_model}")
488
+ console.print()
489
+
490
+ while True:
491
+ try:
492
+ # Get user input
493
+ user_input = session.prompt(get_prompt()).strip()
494
+
495
+ if not user_input:
496
+ continue
497
+
498
+ # Check if input is an image file path (dragged file)
499
+ clean_input = user_input.replace("\\ ", " ")
500
+ if clean_input.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')):
501
+ from ...clipboard import get_image_from_path
502
+ image_data = get_image_from_path(clean_input)
503
+ if image_data:
504
+ base64_data, img_format = image_data
505
+ attached_images.append({"data": base64_data, "format": img_format})
506
+ continue # Prompt again for actual message
507
+
508
+ # Handle slash commands
509
+ if user_input.startswith("/"):
510
+ if not handle_slash_command(user_input):
511
+ break
512
+ continue
513
+
514
+ # Handle quit shortcuts
515
+ if user_input.lower() in ("quit", "exit", "q"):
516
+ break
517
+
518
+ # Expand @file references in the message
519
+ expanded_input, included_files = expand_file_references(user_input)
520
+ if included_files:
521
+ console.print(f"[dim]Including {len(included_files)} file(s): {', '.join(Path(f).name for f in included_files)}[/dim]")
522
+
523
+ # Build options with current mode
524
+ request_options = {
525
+ **options,
526
+ "mode": current_mode.value,
527
+ }
528
+
529
+ # Run agent with current mode
530
+ try:
531
+ # Prepare images for API call
532
+ images_to_send = attached_images if attached_images else None
533
+
534
+ if session_id:
535
+ stream = client.agent_continue_stream(
536
+ session_id, expanded_input, images=images_to_send
537
+ )
538
+ else:
539
+ # Pass loaded_messages from saved session if available
540
+ stream = client.agent_chat_stream(
541
+ message=expanded_input,
542
+ model=model,
543
+ max_iterations=max_iterations,
544
+ options=request_options,
545
+ images=images_to_send,
546
+ history=loaded_messages if loaded_messages else None,
547
+ )
548
+ # Clear loaded_messages after first use
549
+ loaded_messages.clear()
550
+
551
+ # Clear attached images after sending
552
+ attached_images = []
553
+
554
+ # Render the stream and capture any spec output
555
+ result = render_with_interrupt(renderer, stream)
556
+
557
+ # Check if we got a session ID back
558
+ if result and result.get("session_id"):
559
+ session_id = result["session_id"]
560
+
561
+ # Add any pending todos now that we have a session
562
+ if pending_todos:
563
+ for todo_title in pending_todos:
564
+ try:
565
+ client.add_todo(session_id, todo_title)
566
+ except Exception:
567
+ pass # Silently ignore errors adding todos
568
+ pending_todos.clear()
569
+
570
+ # Check for spec output
571
+ if result and result.get("spec"):
572
+ current_spec = result["spec"]
573
+
574
+ # Handle clarifications (may be chained - loop until no more)
575
+ while True:
576
+ clarification = result.get("clarification")
577
+ if not (clarification and session_id):
578
+ break
579
+
580
+ response = get_clarification_response(clarification)
581
+ if not response:
582
+ break
583
+
584
+ # Show the user's selection in the chat
585
+ console.print()
586
+ console.print(f"[dim]Selected:[/dim] [bold]{response}[/bold]")
587
+ console.print()
588
+
589
+ # Use dedicated clarification answer endpoint
590
+ try:
591
+ stream = client.clarification_answer_stream(session_id, response)
592
+ result = render_with_interrupt(renderer, stream)
593
+
594
+ # Update mode if user chose code
595
+ if "code" in response.lower():
596
+ current_mode = AgentMode.CODE
597
+ except Exception as e:
598
+ console.print(f"[red]Error continuing session: {e}[/red]")
599
+ break
600
+
601
+ # Handle plan mode entry request (show approval menu)
602
+ plan_mode_requested = result.get("plan_mode_requested")
603
+ if plan_mode_requested is not None and session_id:
604
+ choice, feedback = show_plan_mode_approval_menu()
605
+
606
+ if choice == "approve":
607
+ current_mode = AgentMode.PLAN
608
+ console.print()
609
+ console.print("[bold green]✓ Plan mode activated[/bold green]")
610
+ console.print()
611
+ # Use the planmode approve endpoint
612
+ stream = client.planmode_approve_stream(session_id)
613
+ result = render_with_interrupt(renderer, stream)
614
+ # After approval, check if there's now a plan submitted
615
+ if result.get("plan_submitted"):
616
+ pass # plan_submitted will be handled below
617
+ elif choice == "feedback":
618
+ # Use the planmode reject endpoint - stay in code mode
619
+ stream = client.planmode_reject_stream(session_id, feedback)
620
+ render_with_interrupt(renderer, stream)
621
+
622
+ # Handle plan mode completion (show approval menu)
623
+ # Only show menu when agent explicitly submits a plan via exit_plan tool
624
+ plan_submitted = result.get("plan_submitted")
625
+ should_show_plan_menu = (
626
+ current_mode == AgentMode.PLAN and
627
+ session_id and
628
+ plan_submitted is not None # Agent called exit_plan tool
629
+ )
630
+ if should_show_plan_menu:
631
+ choice, feedback = show_plan_approval_menu()
632
+
633
+ if choice == "approve":
634
+ current_mode = AgentMode.CODE
635
+ # Use the plan approve endpoint which properly resets mode on server
636
+ stream = client.plan_approve_stream(session_id)
637
+ render_with_interrupt(renderer, stream)
638
+ elif choice == "feedback":
639
+ if feedback:
640
+ # Use the plan reject endpoint which keeps mode as PLAN on server
641
+ stream = client.plan_reject_stream(session_id, feedback)
642
+ render_with_interrupt(renderer, stream)
643
+ else:
644
+ console.print("[dim]No feedback provided[/dim]")
645
+ session_id = None
646
+ current_spec = None
647
+
648
+ console.print()
649
+
650
+ except Exception as e:
651
+ console.print(f"[red]Error: {e}[/red]")
652
+
653
+ except KeyboardInterrupt:
654
+ console.print("\n[dim]Interrupted[/dim]")
655
+ break
656
+ except EOFError:
657
+ break