emdash-cli 0.1.35__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 (30) hide show
  1. emdash_cli/client.py +35 -0
  2. emdash_cli/clipboard.py +30 -61
  3. emdash_cli/commands/agent/__init__.py +14 -0
  4. emdash_cli/commands/agent/cli.py +100 -0
  5. emdash_cli/commands/agent/constants.py +53 -0
  6. emdash_cli/commands/agent/file_utils.py +178 -0
  7. emdash_cli/commands/agent/handlers/__init__.py +41 -0
  8. emdash_cli/commands/agent/handlers/agents.py +421 -0
  9. emdash_cli/commands/agent/handlers/auth.py +69 -0
  10. emdash_cli/commands/agent/handlers/doctor.py +319 -0
  11. emdash_cli/commands/agent/handlers/hooks.py +121 -0
  12. emdash_cli/commands/agent/handlers/mcp.py +183 -0
  13. emdash_cli/commands/agent/handlers/misc.py +200 -0
  14. emdash_cli/commands/agent/handlers/rules.py +394 -0
  15. emdash_cli/commands/agent/handlers/sessions.py +168 -0
  16. emdash_cli/commands/agent/handlers/setup.py +582 -0
  17. emdash_cli/commands/agent/handlers/skills.py +440 -0
  18. emdash_cli/commands/agent/handlers/todos.py +98 -0
  19. emdash_cli/commands/agent/handlers/verify.py +648 -0
  20. emdash_cli/commands/agent/interactive.py +657 -0
  21. emdash_cli/commands/agent/menus.py +728 -0
  22. emdash_cli/commands/agent.py +7 -1321
  23. emdash_cli/commands/server.py +99 -40
  24. emdash_cli/server_manager.py +70 -10
  25. emdash_cli/sse_renderer.py +36 -5
  26. {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.46.dist-info}/METADATA +2 -4
  27. emdash_cli-0.1.46.dist-info/RECORD +49 -0
  28. emdash_cli-0.1.35.dist-info/RECORD +0 -30
  29. {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.46.dist-info}/WHEEL +0 -0
  30. {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.46.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,728 @@
1
+ """Interactive menus for the agent CLI.
2
+
3
+ Contains all prompt_toolkit-based interactive menus.
4
+ """
5
+
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+
10
+ console = Console()
11
+
12
+
13
+ def get_clarification_response(clarification: dict) -> str | None:
14
+ """Get user response for clarification with interactive selection.
15
+
16
+ Args:
17
+ clarification: Dict with question, context, and options
18
+
19
+ Returns:
20
+ User's selected option or typed response, or None if cancelled
21
+ """
22
+ from prompt_toolkit import Application, PromptSession
23
+ from prompt_toolkit.key_binding import KeyBindings
24
+ from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
25
+ from prompt_toolkit.styles import Style
26
+
27
+ options = clarification.get("options", [])
28
+
29
+ if not options:
30
+ # No options, just get free-form input
31
+ session = PromptSession()
32
+ try:
33
+ return session.prompt("response > ").strip() or None
34
+ except (KeyboardInterrupt, EOFError):
35
+ return None
36
+
37
+ selected_index = [0]
38
+ result = [None]
39
+
40
+ # Key bindings
41
+ kb = KeyBindings()
42
+
43
+ @kb.add("up")
44
+ @kb.add("k")
45
+ def move_up(event):
46
+ selected_index[0] = (selected_index[0] - 1) % len(options)
47
+
48
+ @kb.add("down")
49
+ @kb.add("j")
50
+ def move_down(event):
51
+ selected_index[0] = (selected_index[0] + 1) % len(options)
52
+
53
+ @kb.add("enter")
54
+ def select(event):
55
+ result[0] = options[selected_index[0]]
56
+ event.app.exit()
57
+
58
+ # Number key shortcuts (1-9)
59
+ for i in range(min(9, len(options))):
60
+ @kb.add(str(i + 1))
61
+ def select_by_number(event, idx=i):
62
+ result[0] = options[idx]
63
+ event.app.exit()
64
+
65
+ @kb.add("c-c")
66
+ @kb.add("escape")
67
+ def cancel(event):
68
+ result[0] = None
69
+ event.app.exit()
70
+
71
+ @kb.add("o") # 'o' for Other - custom input
72
+ def other_input(event):
73
+ result[0] = "OTHER_INPUT"
74
+ event.app.exit()
75
+
76
+ def get_formatted_options():
77
+ lines = []
78
+ for i, opt in enumerate(options):
79
+ if i == selected_index[0]:
80
+ lines.append(("class:selected", f" ❯ [{i+1}] {opt}\n"))
81
+ else:
82
+ lines.append(("class:option", f" [{i+1}] {opt}\n"))
83
+ lines.append(("class:hint", "\n↑/↓ to move, Enter to select, 1-9 for quick select, o for other"))
84
+ return lines
85
+
86
+ # Style
87
+ style = Style.from_dict({
88
+ "selected": "#00cc66 bold",
89
+ "option": "#888888",
90
+ "hint": "#888888 italic",
91
+ })
92
+
93
+ # Calculate height based on options
94
+ height = len(options) + 2 # options + hint line + padding
95
+
96
+ # Layout
97
+ layout = Layout(
98
+ HSplit([
99
+ Window(
100
+ FormattedTextControl(get_formatted_options),
101
+ height=height,
102
+ ),
103
+ ])
104
+ )
105
+
106
+ # Application
107
+ app = Application(
108
+ layout=layout,
109
+ key_bindings=kb,
110
+ style=style,
111
+ full_screen=False,
112
+ )
113
+
114
+ console.print()
115
+
116
+ try:
117
+ app.run()
118
+ except (KeyboardInterrupt, EOFError):
119
+ return None
120
+
121
+ # Handle "other" option - get custom input
122
+ if result[0] == "OTHER_INPUT":
123
+ session = PromptSession()
124
+ console.print()
125
+ try:
126
+ return session.prompt("response > ").strip() or None
127
+ except (KeyboardInterrupt, EOFError):
128
+ return None
129
+
130
+ # Check if selected option is an "other/explain" type that needs text input
131
+ if result[0]:
132
+ lower_result = result[0].lower()
133
+ needs_input = any(phrase in lower_result for phrase in [
134
+ "something else",
135
+ "other",
136
+ "i'll explain",
137
+ "i will explain",
138
+ "let me explain",
139
+ "custom",
140
+ "none of the above",
141
+ ])
142
+ if needs_input:
143
+ session = PromptSession()
144
+ console.print()
145
+ console.print("[dim]Please explain:[/dim]")
146
+ try:
147
+ custom_input = session.prompt("response > ").strip()
148
+ if custom_input:
149
+ return custom_input
150
+ except (KeyboardInterrupt, EOFError):
151
+ return None
152
+
153
+ return result[0]
154
+
155
+
156
+ def show_plan_approval_menu() -> tuple[str, str]:
157
+ """Show plan approval menu with simple approve/feedback options.
158
+
159
+ Returns:
160
+ Tuple of (choice, user_feedback) where user_feedback is only set for 'feedback'
161
+ """
162
+ from prompt_toolkit import Application, PromptSession
163
+ from prompt_toolkit.key_binding import KeyBindings
164
+ from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
165
+ from prompt_toolkit.styles import Style
166
+
167
+ options = [
168
+ ("approve", "Approve and start implementation"),
169
+ ("feedback", "Provide feedback on the plan"),
170
+ ]
171
+
172
+ selected_index = [0] # Use list to allow mutation in closure
173
+ result = [None]
174
+
175
+ # Key bindings
176
+ kb = KeyBindings()
177
+
178
+ @kb.add("up")
179
+ @kb.add("k")
180
+ def move_up(event):
181
+ selected_index[0] = (selected_index[0] - 1) % len(options)
182
+
183
+ @kb.add("down")
184
+ @kb.add("j")
185
+ def move_down(event):
186
+ selected_index[0] = (selected_index[0] + 1) % len(options)
187
+
188
+ @kb.add("enter")
189
+ def select(event):
190
+ result[0] = options[selected_index[0]][0]
191
+ event.app.exit()
192
+
193
+ @kb.add("1")
194
+ @kb.add("y")
195
+ def select_approve(event):
196
+ result[0] = "approve"
197
+ event.app.exit()
198
+
199
+ @kb.add("2")
200
+ @kb.add("n")
201
+ def select_feedback(event):
202
+ result[0] = "feedback"
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] = "feedback"
210
+ event.app.exit()
211
+
212
+ def get_formatted_options():
213
+ lines = [("class:title", "Approve this plan?\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, y/n for quick select"))
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": "#888888 italic",
232
+ })
233
+
234
+ # Layout
235
+ layout = Layout(
236
+ HSplit([
237
+ Window(
238
+ FormattedTextControl(get_formatted_options),
239
+ height=6,
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] = "feedback"
258
+
259
+ choice = result[0] or "feedback"
260
+
261
+ # Get feedback if feedback was chosen
262
+ user_feedback = ""
263
+ if choice == "feedback":
264
+ console.print()
265
+ console.print("[dim]What changes would you like?[/dim]")
266
+ try:
267
+ session = PromptSession()
268
+ user_feedback = session.prompt("feedback > ").strip()
269
+ except (KeyboardInterrupt, EOFError):
270
+ return "feedback", ""
271
+
272
+ return choice, user_feedback
273
+
274
+
275
+ def show_agents_interactive_menu() -> tuple[str, str]:
276
+ """Show interactive agents menu.
277
+
278
+ Returns:
279
+ Tuple of (action, agent_name) where action is one of:
280
+ - 'view': View agent details
281
+ - 'create': Create new agent
282
+ - 'delete': Delete agent
283
+ - 'edit': Edit agent file
284
+ - 'cancel': User cancelled
285
+ """
286
+ from prompt_toolkit import Application
287
+ from prompt_toolkit.key_binding import KeyBindings
288
+ from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
289
+ from prompt_toolkit.styles import Style
290
+ from emdash_core.agent.toolkits import list_agent_types, get_custom_agent
291
+
292
+ # Get all agents
293
+ all_agents = list_agent_types(Path.cwd())
294
+ builtin = ["Explore", "Plan"]
295
+ custom = [a for a in all_agents if a not in builtin]
296
+
297
+ # Build menu items: each is (name, description, is_builtin, is_action)
298
+ menu_items = []
299
+
300
+ # Add built-in agents
301
+ menu_items.append(("Explore", "Fast codebase exploration (read-only)", True, False))
302
+ menu_items.append(("Plan", "Design implementation plans", True, False))
303
+
304
+ # Add custom agents
305
+ for name in custom:
306
+ agent = get_custom_agent(name, Path.cwd())
307
+ desc = agent.description if agent else "Custom agent"
308
+ menu_items.append((name, desc, False, False))
309
+
310
+ # Add action items at the bottom
311
+ menu_items.append(("+ Create New Agent", "Create a new custom agent", False, True))
312
+
313
+ selected_index = [0]
314
+ result = [("cancel", "")]
315
+
316
+ kb = KeyBindings()
317
+
318
+ @kb.add("up")
319
+ @kb.add("k")
320
+ def move_up(event):
321
+ selected_index[0] = (selected_index[0] - 1) % len(menu_items)
322
+
323
+ @kb.add("down")
324
+ @kb.add("j")
325
+ def move_down(event):
326
+ selected_index[0] = (selected_index[0] + 1) % len(menu_items)
327
+
328
+ @kb.add("enter")
329
+ def select(event):
330
+ item = menu_items[selected_index[0]]
331
+ name, desc, is_builtin, is_action = item
332
+ if is_action:
333
+ if "Create" in name:
334
+ result[0] = ("create", "")
335
+ else:
336
+ result[0] = ("view", name)
337
+ event.app.exit()
338
+
339
+ @kb.add("d")
340
+ def delete_agent(event):
341
+ item = menu_items[selected_index[0]]
342
+ name, desc, is_builtin, is_action = item
343
+ if not is_builtin and not is_action:
344
+ result[0] = ("delete", name)
345
+ event.app.exit()
346
+
347
+ @kb.add("e")
348
+ def edit_agent(event):
349
+ item = menu_items[selected_index[0]]
350
+ name, desc, is_builtin, is_action = item
351
+ if not is_builtin and not is_action:
352
+ result[0] = ("edit", name)
353
+ event.app.exit()
354
+
355
+ @kb.add("n")
356
+ def new_agent(event):
357
+ result[0] = ("create", "")
358
+ event.app.exit()
359
+
360
+ @kb.add("c-c")
361
+ @kb.add("escape")
362
+ @kb.add("q")
363
+ def cancel(event):
364
+ result[0] = ("cancel", "")
365
+ event.app.exit()
366
+
367
+ def get_formatted_menu():
368
+ lines = [("class:title", "Agents\n\n")]
369
+
370
+ for i, (name, desc, is_builtin, is_action) in enumerate(menu_items):
371
+ is_selected = i == selected_index[0]
372
+ prefix = "❯ " if is_selected else " "
373
+
374
+ if is_action:
375
+ # Action item (like Create New)
376
+ if is_selected:
377
+ lines.append(("class:action-selected", f"{prefix}{name}\n"))
378
+ else:
379
+ lines.append(("class:action", f"{prefix}{name}\n"))
380
+ elif is_builtin:
381
+ # Built-in agent
382
+ if is_selected:
383
+ lines.append(("class:builtin-selected", f"{prefix}{name}"))
384
+ lines.append(("class:desc-selected", f" - {desc}\n"))
385
+ else:
386
+ lines.append(("class:builtin", f"{prefix}{name}"))
387
+ lines.append(("class:desc", f" - {desc}\n"))
388
+ else:
389
+ # Custom agent
390
+ if is_selected:
391
+ lines.append(("class:custom-selected", f"{prefix}{name}"))
392
+ lines.append(("class:desc-selected", f" - {desc}\n"))
393
+ else:
394
+ lines.append(("class:custom", f"{prefix}{name}"))
395
+ lines.append(("class:desc", f" - {desc}\n"))
396
+
397
+ lines.append(("class:hint", "\n↑/↓ navigate • Enter view • n new • e edit • d delete • q quit"))
398
+ return lines
399
+
400
+ style = Style.from_dict({
401
+ "title": "#00ccff bold",
402
+ "builtin": "#888888",
403
+ "builtin-selected": "#00cc66 bold",
404
+ "custom": "#00ccff",
405
+ "custom-selected": "#00cc66 bold",
406
+ "action": "#ffcc00",
407
+ "action-selected": "#ffcc00 bold",
408
+ "desc": "#666666",
409
+ "desc-selected": "#00cc66",
410
+ "hint": "#888888 italic",
411
+ })
412
+
413
+ height = len(menu_items) + 4 # items + title + hint + padding
414
+
415
+ layout = Layout(
416
+ HSplit([
417
+ Window(
418
+ FormattedTextControl(get_formatted_menu),
419
+ height=height,
420
+ ),
421
+ ])
422
+ )
423
+
424
+ app = Application(
425
+ layout=layout,
426
+ key_bindings=kb,
427
+ style=style,
428
+ full_screen=False,
429
+ )
430
+
431
+ console.print()
432
+
433
+ try:
434
+ app.run()
435
+ except (KeyboardInterrupt, EOFError):
436
+ result[0] = ("cancel", "")
437
+
438
+ return result[0]
439
+
440
+
441
+ def prompt_agent_name() -> str:
442
+ """Prompt user for new agent name."""
443
+ from prompt_toolkit import PromptSession
444
+
445
+ console.print()
446
+ console.print("[bold]Create New Agent[/bold]")
447
+ console.print("[dim]Enter a name for your agent (e.g., code-reviewer, bug-finder)[/dim]")
448
+
449
+ try:
450
+ session = PromptSession()
451
+ name = session.prompt("Agent name > ").strip()
452
+ return name.lower().replace(" ", "-") if name else ""
453
+ except (KeyboardInterrupt, EOFError):
454
+ return ""
455
+
456
+
457
+ def confirm_delete(agent_name: str) -> bool:
458
+ """Confirm agent deletion."""
459
+ from prompt_toolkit import PromptSession
460
+
461
+ console.print()
462
+ console.print(f"[yellow]Delete agent '{agent_name}'?[/yellow]")
463
+ console.print("[dim]This will remove the agent file. Type 'yes' to confirm.[/dim]")
464
+
465
+ try:
466
+ session = PromptSession()
467
+ response = session.prompt("Confirm > ").strip().lower()
468
+ return response in ("yes", "y")
469
+ except (KeyboardInterrupt, EOFError):
470
+ return False
471
+
472
+
473
+ def show_sessions_interactive_menu(sessions: list, active_session: str | None) -> tuple[str, str]:
474
+ """Show interactive sessions menu.
475
+
476
+ Args:
477
+ sessions: List of SessionInfo objects
478
+ active_session: Name of currently active session
479
+
480
+ Returns:
481
+ Tuple of (action, session_name) where action is one of:
482
+ - 'load': Load the session
483
+ - 'delete': Delete the session
484
+ - 'cancel': User cancelled
485
+ """
486
+ from prompt_toolkit import Application
487
+ from prompt_toolkit.key_binding import KeyBindings
488
+ from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
489
+ from prompt_toolkit.styles import Style
490
+
491
+ if not sessions:
492
+ return ("cancel", "")
493
+
494
+ # Build menu items: (name, summary, mode, message_count, updated_at, is_active)
495
+ menu_items = []
496
+ for s in sessions:
497
+ is_active = active_session == s.name
498
+ menu_items.append((s.name, s.summary or "", s.mode, s.message_count, s.updated_at, is_active))
499
+
500
+ selected_index = [0]
501
+ result = [("cancel", "")]
502
+
503
+ kb = KeyBindings()
504
+
505
+ @kb.add("up")
506
+ @kb.add("k")
507
+ def move_up(event):
508
+ selected_index[0] = (selected_index[0] - 1) % len(menu_items)
509
+
510
+ @kb.add("down")
511
+ @kb.add("j")
512
+ def move_down(event):
513
+ selected_index[0] = (selected_index[0] + 1) % len(menu_items)
514
+
515
+ @kb.add("enter")
516
+ def select(event):
517
+ item = menu_items[selected_index[0]]
518
+ result[0] = ("load", item[0])
519
+ event.app.exit()
520
+
521
+ @kb.add("d")
522
+ def delete_session(event):
523
+ item = menu_items[selected_index[0]]
524
+ result[0] = ("delete", item[0])
525
+ event.app.exit()
526
+
527
+ @kb.add("c-c")
528
+ @kb.add("escape")
529
+ @kb.add("q")
530
+ def cancel(event):
531
+ result[0] = ("cancel", "")
532
+ event.app.exit()
533
+
534
+ def get_formatted_menu():
535
+ lines = [("class:title", "Sessions\n\n")]
536
+
537
+ for i, (name, summary, mode, msg_count, updated, is_active) in enumerate(menu_items):
538
+ is_selected = i == selected_index[0]
539
+ prefix = "❯ " if is_selected else " "
540
+ active_marker = " *" if is_active else ""
541
+
542
+ if is_selected:
543
+ lines.append(("class:name-selected", f"{prefix}{name}{active_marker}"))
544
+ lines.append(("class:mode-selected", f" [{mode}]"))
545
+ lines.append(("class:info-selected", f" {msg_count} msgs\n"))
546
+ if summary:
547
+ truncated = summary[:50] + "..." if len(summary) > 50 else summary
548
+ lines.append(("class:summary-selected", f" {truncated}\n"))
549
+ else:
550
+ lines.append(("class:name", f"{prefix}{name}{active_marker}"))
551
+ lines.append(("class:mode", f" [{mode}]"))
552
+ lines.append(("class:info", f" {msg_count} msgs\n"))
553
+ if summary:
554
+ truncated = summary[:50] + "..." if len(summary) > 50 else summary
555
+ lines.append(("class:summary", f" {truncated}\n"))
556
+
557
+ lines.append(("class:hint", "\n↑/↓ navigate • Enter load • d delete • q quit"))
558
+ return lines
559
+
560
+ style = Style.from_dict({
561
+ "title": "#00ccff bold",
562
+ "name": "#00ccff",
563
+ "name-selected": "#00cc66 bold",
564
+ "mode": "#888888",
565
+ "mode-selected": "#00cc66",
566
+ "info": "#666666",
567
+ "info-selected": "#00cc66",
568
+ "summary": "#555555 italic",
569
+ "summary-selected": "#00cc66 italic",
570
+ "hint": "#444444 italic",
571
+ })
572
+
573
+ height = len(menu_items) * 2 + 4 # items (with summaries) + title + hint + padding
574
+
575
+ layout = Layout(
576
+ HSplit([
577
+ Window(
578
+ FormattedTextControl(get_formatted_menu),
579
+ height=height,
580
+ ),
581
+ ])
582
+ )
583
+
584
+ app = Application(
585
+ layout=layout,
586
+ key_bindings=kb,
587
+ style=style,
588
+ full_screen=False,
589
+ )
590
+
591
+ console.print()
592
+
593
+ try:
594
+ app.run()
595
+ except (KeyboardInterrupt, EOFError):
596
+ result[0] = ("cancel", "")
597
+
598
+ return result[0]
599
+
600
+
601
+ def confirm_session_delete(session_name: str) -> bool:
602
+ """Confirm session deletion."""
603
+ from prompt_toolkit import PromptSession
604
+
605
+ console.print()
606
+ console.print(f"[yellow]Delete session '{session_name}'?[/yellow]")
607
+ console.print("[dim]Type 'yes' to confirm.[/dim]")
608
+
609
+ try:
610
+ session = PromptSession()
611
+ response = session.prompt("Confirm > ").strip().lower()
612
+ return response in ("yes", "y")
613
+ except (KeyboardInterrupt, EOFError):
614
+ return False
615
+
616
+
617
+ def show_plan_mode_approval_menu() -> tuple[str, str]:
618
+ """Show plan mode entry approval menu.
619
+
620
+ Returns:
621
+ Tuple of (choice, feedback) where feedback is only set for 'reject'
622
+ """
623
+ from prompt_toolkit import Application, PromptSession
624
+ from prompt_toolkit.key_binding import KeyBindings
625
+ from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
626
+ from prompt_toolkit.styles import Style
627
+
628
+ options = [
629
+ ("approve", "Enter plan mode and explore"),
630
+ ("reject", "Skip planning, proceed directly"),
631
+ ]
632
+
633
+ selected_index = [0]
634
+ result = [None]
635
+
636
+ kb = KeyBindings()
637
+
638
+ @kb.add("up")
639
+ @kb.add("k")
640
+ def move_up(event):
641
+ selected_index[0] = (selected_index[0] - 1) % len(options)
642
+
643
+ @kb.add("down")
644
+ @kb.add("j")
645
+ def move_down(event):
646
+ selected_index[0] = (selected_index[0] + 1) % len(options)
647
+
648
+ @kb.add("enter")
649
+ def select(event):
650
+ result[0] = options[selected_index[0]][0]
651
+ event.app.exit()
652
+
653
+ @kb.add("1")
654
+ @kb.add("y")
655
+ def select_approve(event):
656
+ result[0] = "approve"
657
+ event.app.exit()
658
+
659
+ @kb.add("2")
660
+ @kb.add("n")
661
+ def select_reject(event):
662
+ result[0] = "reject"
663
+ event.app.exit()
664
+
665
+ @kb.add("c-c")
666
+ @kb.add("q")
667
+ @kb.add("escape")
668
+ def cancel(event):
669
+ result[0] = "reject"
670
+ event.app.exit()
671
+
672
+ def get_formatted_options():
673
+ lines = [("class:title", "Enter plan mode?\n\n")]
674
+ for i, (key, desc) in enumerate(options):
675
+ if i == selected_index[0]:
676
+ lines.append(("class:selected", f" ❯ {key:8} "))
677
+ lines.append(("class:selected-desc", f"- {desc}\n"))
678
+ else:
679
+ lines.append(("class:option", f" {key:8} "))
680
+ lines.append(("class:desc", f"- {desc}\n"))
681
+ lines.append(("class:hint", "\n↑/↓ to move, Enter to select, y/n for quick select"))
682
+ return lines
683
+
684
+ style = Style.from_dict({
685
+ "title": "#ffcc00 bold",
686
+ "selected": "#00cc66 bold",
687
+ "selected-desc": "#00cc66",
688
+ "option": "#888888",
689
+ "desc": "#666666",
690
+ "hint": "#888888 italic",
691
+ })
692
+
693
+ layout = Layout(
694
+ HSplit([
695
+ Window(
696
+ FormattedTextControl(get_formatted_options),
697
+ height=6,
698
+ ),
699
+ ])
700
+ )
701
+
702
+ app = Application(
703
+ layout=layout,
704
+ key_bindings=kb,
705
+ style=style,
706
+ full_screen=False,
707
+ )
708
+
709
+ console.print()
710
+
711
+ try:
712
+ app.run()
713
+ except (KeyboardInterrupt, EOFError):
714
+ result[0] = "reject"
715
+
716
+ choice = result[0] or "reject"
717
+
718
+ feedback = ""
719
+ if choice == "reject":
720
+ console.print()
721
+ console.print("[dim]Reason for skipping plan mode (optional):[/dim]")
722
+ try:
723
+ session = PromptSession()
724
+ feedback = session.prompt("feedback > ").strip()
725
+ except (KeyboardInterrupt, EOFError):
726
+ return "reject", ""
727
+
728
+ return choice, feedback