emdash-cli 0.1.35__py3-none-any.whl → 0.1.67__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 (50) hide show
  1. emdash_cli/client.py +41 -22
  2. emdash_cli/clipboard.py +30 -61
  3. emdash_cli/commands/__init__.py +2 -2
  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 +63 -0
  7. emdash_cli/commands/agent/file_utils.py +178 -0
  8. emdash_cli/commands/agent/handlers/__init__.py +51 -0
  9. emdash_cli/commands/agent/handlers/agents.py +449 -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/index.py +183 -0
  14. emdash_cli/commands/agent/handlers/mcp.py +183 -0
  15. emdash_cli/commands/agent/handlers/misc.py +319 -0
  16. emdash_cli/commands/agent/handlers/registry.py +72 -0
  17. emdash_cli/commands/agent/handlers/rules.py +411 -0
  18. emdash_cli/commands/agent/handlers/sessions.py +168 -0
  19. emdash_cli/commands/agent/handlers/setup.py +715 -0
  20. emdash_cli/commands/agent/handlers/skills.py +478 -0
  21. emdash_cli/commands/agent/handlers/telegram.py +475 -0
  22. emdash_cli/commands/agent/handlers/todos.py +119 -0
  23. emdash_cli/commands/agent/handlers/verify.py +653 -0
  24. emdash_cli/commands/agent/help.py +236 -0
  25. emdash_cli/commands/agent/interactive.py +842 -0
  26. emdash_cli/commands/agent/menus.py +760 -0
  27. emdash_cli/commands/agent/onboarding.py +619 -0
  28. emdash_cli/commands/agent/session_restore.py +210 -0
  29. emdash_cli/commands/agent.py +7 -1321
  30. emdash_cli/commands/index.py +111 -13
  31. emdash_cli/commands/registry.py +635 -0
  32. emdash_cli/commands/server.py +99 -40
  33. emdash_cli/commands/skills.py +72 -6
  34. emdash_cli/design.py +328 -0
  35. emdash_cli/diff_renderer.py +438 -0
  36. emdash_cli/integrations/__init__.py +1 -0
  37. emdash_cli/integrations/telegram/__init__.py +15 -0
  38. emdash_cli/integrations/telegram/bot.py +402 -0
  39. emdash_cli/integrations/telegram/bridge.py +865 -0
  40. emdash_cli/integrations/telegram/config.py +155 -0
  41. emdash_cli/integrations/telegram/formatter.py +385 -0
  42. emdash_cli/main.py +52 -2
  43. emdash_cli/server_manager.py +70 -10
  44. emdash_cli/sse_renderer.py +659 -167
  45. {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/METADATA +2 -4
  46. emdash_cli-0.1.67.dist-info/RECORD +63 -0
  47. emdash_cli/commands/swarm.py +0 -86
  48. emdash_cli-0.1.35.dist-info/RECORD +0 -30
  49. {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/WHEEL +0 -0
  50. {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,842 @@
1
+ """Interactive REPL mode for the agent CLI."""
2
+
3
+ import subprocess
4
+ import sys
5
+ import time
6
+ import threading
7
+ from pathlib import Path
8
+
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.markdown import Markdown
12
+
13
+ from .constants import AgentMode, SLASH_COMMANDS
14
+ from .onboarding import is_first_run, run_onboarding
15
+ from .help import show_command_help
16
+ from .session_restore import get_recent_session, show_session_restore_prompt
17
+ from ...design import (
18
+ header, footer, Colors, STATUS_ACTIVE, DOT_BULLET,
19
+ ARROW_PROMPT, SEPARATOR_WIDTH,
20
+ )
21
+
22
+
23
+ def show_welcome_banner(
24
+ version: str,
25
+ git_repo: str | None,
26
+ git_branch: str | None,
27
+ mode: str,
28
+ model: str,
29
+ console: Console,
30
+ ) -> None:
31
+ """Display clean welcome banner with zen styling."""
32
+ console.print()
33
+
34
+ # Simple header
35
+ console.print(f"[{Colors.MUTED}]{header('emdash', SEPARATOR_WIDTH)}[/{Colors.MUTED}]")
36
+ console.print(f" [{Colors.DIM}]v{version}[/{Colors.DIM}]")
37
+ console.print()
38
+
39
+ # Info section
40
+ if git_repo:
41
+ branch_display = f" [{Colors.WARNING}]{git_branch}[/{Colors.WARNING}]" if git_branch else ""
42
+ console.print(f" [{Colors.DIM}]repo[/{Colors.DIM}] [{Colors.SUCCESS}]{git_repo}[/{Colors.SUCCESS}]{branch_display}")
43
+
44
+ mode_color = Colors.WARNING if mode == "plan" else Colors.SUCCESS
45
+ console.print(f" [{Colors.DIM}]mode[/{Colors.DIM}] [{mode_color}]{mode}[/{mode_color}]")
46
+ console.print(f" [{Colors.DIM}]model[/{Colors.DIM}] [{Colors.MUTED}]{model}[/{Colors.MUTED}]")
47
+ console.print()
48
+
49
+ console.print(f"[{Colors.MUTED}]{footer(SEPARATOR_WIDTH)}[/{Colors.MUTED}]")
50
+ console.print()
51
+
52
+ # Quick tips
53
+ console.print(f" [{Colors.DIM}]› /help commands › @file include files › Ctrl+C cancel[/{Colors.DIM}]")
54
+ console.print()
55
+ from .file_utils import expand_file_references, fuzzy_find_files
56
+ from .menus import (
57
+ get_clarification_response,
58
+ show_plan_approval_menu,
59
+ show_plan_mode_approval_menu,
60
+ )
61
+ from .handlers import (
62
+ handle_agents,
63
+ handle_session,
64
+ handle_todos,
65
+ handle_todo_add,
66
+ handle_hooks,
67
+ handle_rules,
68
+ handle_skills,
69
+ handle_index,
70
+ handle_mcp,
71
+ handle_registry,
72
+ handle_auth,
73
+ handle_doctor,
74
+ handle_verify,
75
+ handle_verify_loop,
76
+ handle_setup,
77
+ handle_status,
78
+ handle_pr,
79
+ handle_projectmd,
80
+ handle_research,
81
+ handle_context,
82
+ handle_compact,
83
+ handle_diff,
84
+ handle_telegram,
85
+ )
86
+
87
+ console = Console()
88
+
89
+
90
+ def render_with_interrupt(renderer, stream) -> dict:
91
+ """Render stream with ESC key interrupt support.
92
+
93
+ Args:
94
+ renderer: SSE renderer instance
95
+ stream: SSE stream iterator
96
+
97
+ Returns:
98
+ Result dict from renderer, with 'interrupted' flag
99
+ """
100
+ from ...keyboard import KeyListener
101
+
102
+ interrupt_event = threading.Event()
103
+
104
+ def on_escape():
105
+ interrupt_event.set()
106
+
107
+ listener = KeyListener(on_escape)
108
+
109
+ try:
110
+ listener.start()
111
+ result = renderer.render_stream(stream, interrupt_event=interrupt_event)
112
+ return result
113
+ finally:
114
+ listener.stop()
115
+
116
+
117
+ def run_single_task(
118
+ client,
119
+ renderer,
120
+ task: str,
121
+ model: str | None,
122
+ max_iterations: int,
123
+ options: dict,
124
+ ):
125
+ """Run a single agent task."""
126
+ import click
127
+
128
+ try:
129
+ stream = client.agent_chat_stream(
130
+ message=task,
131
+ model=model,
132
+ max_iterations=max_iterations,
133
+ options=options,
134
+ )
135
+ result = render_with_interrupt(renderer, stream)
136
+ if result.get("interrupted"):
137
+ console.print("[dim]Task interrupted. You can continue or start a new task.[/dim]")
138
+ except Exception as e:
139
+ console.print(f"[red]Error: {e}[/red]")
140
+ raise click.Abort()
141
+
142
+
143
+ def run_slash_command_task(
144
+ client,
145
+ renderer,
146
+ model: str | None,
147
+ max_iterations: int,
148
+ task: str,
149
+ options: dict,
150
+ ):
151
+ """Run a task from a slash command."""
152
+ try:
153
+ stream = client.agent_chat_stream(
154
+ message=task,
155
+ model=model,
156
+ max_iterations=max_iterations,
157
+ options=options,
158
+ )
159
+ result = render_with_interrupt(renderer, stream)
160
+ if result.get("interrupted"):
161
+ console.print("[dim]Task interrupted.[/dim]")
162
+ console.print()
163
+ except Exception as e:
164
+ console.print(f"[red]Error: {e}[/red]")
165
+
166
+
167
+ def run_interactive(
168
+ client,
169
+ renderer,
170
+ model: str | None,
171
+ max_iterations: int,
172
+ options: dict,
173
+ ):
174
+ """Run interactive REPL mode with slash commands."""
175
+ from prompt_toolkit import PromptSession
176
+ from prompt_toolkit.history import FileHistory
177
+ from prompt_toolkit.completion import Completer, Completion
178
+ from prompt_toolkit.styles import Style
179
+ from prompt_toolkit.key_binding import KeyBindings
180
+
181
+ # Current mode
182
+ current_mode = AgentMode(options.get("mode", "code"))
183
+ session_id = None
184
+ current_spec = None
185
+ # Attached images for next message
186
+ attached_images: list[dict] = []
187
+ # Loaded messages from saved session (for restoration)
188
+ loaded_messages: list[dict] = []
189
+ # Pending todos to add when session starts
190
+ pending_todos: list[str] = []
191
+
192
+ # Style for prompt (emdash signature style)
193
+ # Toolbar info (will be set later, but need closure access)
194
+ toolbar_branch: str | None = None
195
+ toolbar_model: str = "unknown"
196
+
197
+ PROMPT_STYLE = Style.from_dict({
198
+ "prompt.mode.plan": f"{Colors.WARNING} bold",
199
+ "prompt.mode.code": f"{Colors.PRIMARY} bold",
200
+ "prompt.prefix": Colors.MUTED,
201
+ "prompt.cursor": f"{Colors.PRIMARY}",
202
+ "prompt.image": Colors.ACCENT,
203
+ "completion-menu": "bg:#1a1a2e #e8ecf0",
204
+ "completion-menu.completion": "bg:#1a1a2e #e8ecf0",
205
+ "completion-menu.completion.current": f"bg:#2a2a3e {Colors.SUCCESS} bold",
206
+ "completion-menu.meta.completion": f"bg:#1a1a2e {Colors.MUTED}",
207
+ "completion-menu.meta.completion.current": f"bg:#2a2a3e {Colors.SUBTLE}",
208
+ "command": f"{Colors.PRIMARY} bold",
209
+ # Zen bottom toolbar styles
210
+ "bottom-toolbar": f"bg:#1a1a1a {Colors.DIM}",
211
+ "bottom-toolbar.brand": f"bg:#1a1a1a {Colors.PRIMARY}",
212
+ "bottom-toolbar.branch": f"bg:#1a1a1a {Colors.WARNING}",
213
+ "bottom-toolbar.model": f"bg:#1a1a1a {Colors.ACCENT}",
214
+ "bottom-toolbar.mode-code": f"bg:#1a1a1a {Colors.SUCCESS}",
215
+ "bottom-toolbar.mode-plan": f"bg:#1a1a1a {Colors.WARNING}",
216
+ "bottom-toolbar.session": f"bg:#1a1a1a {Colors.SUCCESS}",
217
+ "bottom-toolbar.no-session": f"bg:#1a1a1a {Colors.MUTED}",
218
+ })
219
+
220
+ class SlashCommandCompleter(Completer):
221
+ """Completer for slash commands and @file references."""
222
+
223
+ def get_completions(self, document, complete_event):
224
+ text = document.text_before_cursor
225
+
226
+ # Handle @file completions
227
+ # Find the last @ in the text
228
+ at_idx = text.rfind('@')
229
+ if at_idx != -1:
230
+ # Get the query after @
231
+ query = text[at_idx + 1:]
232
+ # Only complete if query has at least 1 char and no space after @
233
+ if query and ' ' not in query:
234
+ matches = fuzzy_find_files(query, limit=10)
235
+ cwd = Path.cwd()
236
+ for match in matches:
237
+ try:
238
+ rel_path = match.relative_to(cwd)
239
+ except ValueError:
240
+ rel_path = match
241
+ # Replace from @ onwards
242
+ yield Completion(
243
+ f"@{rel_path}",
244
+ start_position=-(len(query) + 1), # +1 for @
245
+ display=str(rel_path),
246
+ display_meta="file",
247
+ )
248
+ return
249
+
250
+ # Handle slash commands
251
+ if not text.startswith("/"):
252
+ return
253
+ for cmd, description in SLASH_COMMANDS.items():
254
+ # Extract base command (e.g., "/pr" from "/pr [url]")
255
+ base_cmd = cmd.split()[0]
256
+ if base_cmd.startswith(text):
257
+ yield Completion(
258
+ base_cmd,
259
+ start_position=-len(text),
260
+ display=cmd,
261
+ display_meta=description,
262
+ )
263
+
264
+ # Setup history file
265
+ history_file = Path.home() / ".emdash" / "cli_history"
266
+ history_file.parent.mkdir(parents=True, exist_ok=True)
267
+ history = FileHistory(str(history_file))
268
+
269
+ # Key bindings: Enter submits, Alt+Enter inserts newline
270
+ kb = KeyBindings()
271
+
272
+ @kb.add("enter", eager=True)
273
+ def submit_on_enter(event):
274
+ """Submit on Enter."""
275
+ event.current_buffer.validate_and_handle()
276
+
277
+ @kb.add("escape", "enter") # Alt+Enter (Escape then Enter)
278
+ @kb.add("c-j") # Ctrl+J as alternative for newline
279
+ def insert_newline_alt(event):
280
+ """Insert a newline character with Alt+Enter or Ctrl+J."""
281
+ event.current_buffer.insert_text("\n")
282
+
283
+ @kb.add("c-v") # Ctrl+V to paste (check for images)
284
+ def paste_with_image_check(event):
285
+ """Paste text or attach image from clipboard."""
286
+ nonlocal attached_images
287
+ from ...clipboard import get_clipboard_image, get_image_from_path
288
+
289
+ # Try to get image from clipboard
290
+ image_data = get_clipboard_image()
291
+ if image_data:
292
+ base64_data, img_format = image_data
293
+ attached_images.append({"data": base64_data, "format": img_format})
294
+ # Show feedback that image was attached
295
+ console.print(f" [{Colors.SUCCESS}]✓ Image {len(attached_images)} attached[/{Colors.SUCCESS}]")
296
+ # Refresh prompt to show updated image list
297
+ event.app.invalidate()
298
+ return
299
+
300
+ # Check if clipboard contains an image file path
301
+ clipboard_data = event.app.clipboard.get_data()
302
+ if clipboard_data and clipboard_data.text:
303
+ text = clipboard_data.text.strip()
304
+ # Remove escape characters from dragged paths (e.g., "path\ with\ spaces")
305
+ clean_path = text.replace("\\ ", " ")
306
+ # Check if it looks like an image file path
307
+ if clean_path.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')):
308
+ image_data = get_image_from_path(clean_path)
309
+ if image_data:
310
+ base64_data, img_format = image_data
311
+ attached_images.append({"data": base64_data, "format": img_format})
312
+ event.app.invalidate()
313
+ return
314
+
315
+ # No image, do normal paste
316
+ event.current_buffer.paste_clipboard_data(clipboard_data)
317
+
318
+ def check_for_image_path(buff):
319
+ """Check if buffer contains an image path and attach it."""
320
+ nonlocal attached_images
321
+ text = buff.text.strip()
322
+ if not text:
323
+ return
324
+ # Clean escaped spaces from dragged paths
325
+ clean_text = text.replace("\\ ", " ")
326
+ if clean_text.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')):
327
+ from ...clipboard import get_image_from_path
328
+ from prompt_toolkit.application import get_app
329
+ image_data = get_image_from_path(clean_text)
330
+ if image_data:
331
+ base64_data, img_format = image_data
332
+ attached_images.append({"data": base64_data, "format": img_format})
333
+ # Clear the buffer
334
+ buff.text = ""
335
+ buff.cursor_position = 0
336
+ # Refresh prompt to show image indicator
337
+ try:
338
+ get_app().invalidate()
339
+ except Exception:
340
+ pass
341
+
342
+ def get_bottom_toolbar():
343
+ """Bottom status bar with zen aesthetic - em-dashes and warm colors."""
344
+ nonlocal current_mode, session_id, toolbar_branch, toolbar_model
345
+
346
+ # Zen symbols
347
+ em = "─"
348
+ dot = "∷"
349
+
350
+ # Build toolbar with zen aesthetic
351
+ parts = [
352
+ ("class:bottom-toolbar", f" {em}{em} "),
353
+ ("class:bottom-toolbar.brand", "◈ emdash"),
354
+ ]
355
+
356
+ # Branch with stippled bullet
357
+ if toolbar_branch:
358
+ parts.append(("class:bottom-toolbar", f" {dot} "))
359
+ parts.append(("class:bottom-toolbar.branch", toolbar_branch))
360
+
361
+ # Model with stippled bullet
362
+ if toolbar_model and toolbar_model != "unknown":
363
+ parts.append(("class:bottom-toolbar", f" {dot} "))
364
+ parts.append(("class:bottom-toolbar.model", toolbar_model))
365
+
366
+ # Mode indicator
367
+ parts.append(("class:bottom-toolbar", f" {em}{em} "))
368
+ if current_mode == AgentMode.PLAN:
369
+ parts.append(("class:bottom-toolbar.mode-plan", "▹ plan"))
370
+ else:
371
+ parts.append(("class:bottom-toolbar.mode-code", "▸ code"))
372
+
373
+ # Session indicator
374
+ if session_id:
375
+ parts.append(("class:bottom-toolbar.session", " ●"))
376
+ else:
377
+ parts.append(("class:bottom-toolbar.no-session", " ○"))
378
+
379
+ parts.append(("class:bottom-toolbar", " "))
380
+
381
+ return parts
382
+
383
+ session = PromptSession(
384
+ history=history,
385
+ completer=SlashCommandCompleter(),
386
+ style=PROMPT_STYLE,
387
+ complete_while_typing=True,
388
+ multiline=True,
389
+ prompt_continuation=" ",
390
+ key_bindings=kb,
391
+ bottom_toolbar=get_bottom_toolbar,
392
+ )
393
+
394
+ # Watch for image paths being pasted/dropped
395
+ session.default_buffer.on_text_changed += check_for_image_path
396
+
397
+ def get_prompt():
398
+ """Get formatted prompt with distinctive emdash styling."""
399
+ nonlocal attached_images, current_mode
400
+ parts = []
401
+ # Show attached images above prompt
402
+ if attached_images:
403
+ image_tags = " ".join(f"[Image {i+1}]" for i in range(len(attached_images)))
404
+ parts.append(("class:prompt.image", f" {image_tags}\n"))
405
+ # Distinctive em-dash prompt with mode indicator
406
+ mode_class = "class:prompt.mode.plan" if current_mode == AgentMode.PLAN else "class:prompt.mode.code"
407
+ # Use em-dash as the signature prompt element
408
+ parts.append(("class:prompt.prefix", " "))
409
+ parts.append((mode_class, f"─── "))
410
+ parts.append(("class:prompt.cursor", "█ "))
411
+ return parts
412
+
413
+ def show_help():
414
+ """Show available commands with zen styling."""
415
+ console.print()
416
+ console.print(f"[{Colors.MUTED}]{header('Commands', SEPARATOR_WIDTH)}[/{Colors.MUTED}]")
417
+ console.print()
418
+ for cmd, desc in SLASH_COMMANDS.items():
419
+ console.print(f" [{Colors.PRIMARY}]{cmd:18}[/{Colors.PRIMARY}] [{Colors.DIM}]{desc}[/{Colors.DIM}]")
420
+ console.print()
421
+ console.print(f"[{Colors.MUTED}]{footer(SEPARATOR_WIDTH)}[/{Colors.MUTED}]")
422
+ console.print()
423
+ console.print(f" [{Colors.DIM}]Type your task or question to interact with the agent.[/{Colors.DIM}]")
424
+ console.print()
425
+
426
+ def handle_slash_command(cmd: str) -> bool:
427
+ """Handle a slash command. Returns True if should continue, False to exit."""
428
+ nonlocal current_mode, session_id, current_spec, pending_todos
429
+
430
+ cmd_parts = cmd.strip().split(maxsplit=1)
431
+ command = cmd_parts[0].lower()
432
+ args = cmd_parts[1] if len(cmd_parts) > 1 else ""
433
+
434
+ if command == "/quit" or command == "/exit" or command == "/q":
435
+ return False
436
+
437
+ elif command == "/help":
438
+ if args:
439
+ # Show contextual help for specific command
440
+ show_command_help(args)
441
+ else:
442
+ show_help()
443
+
444
+ elif command == "/plan":
445
+ current_mode = AgentMode.PLAN
446
+ # Reset session so next chat creates a new session with plan mode
447
+ if session_id:
448
+ session_id = None
449
+ console.print(f" [{Colors.SUCCESS}]{STATUS_ACTIVE}[/{Colors.SUCCESS}] [{Colors.WARNING}]plan mode[/{Colors.WARNING}] [{Colors.DIM}](session reset)[/{Colors.DIM}]")
450
+ else:
451
+ console.print(f" [{Colors.SUCCESS}]{STATUS_ACTIVE}[/{Colors.SUCCESS}] [{Colors.WARNING}]plan mode[/{Colors.WARNING}]")
452
+
453
+ elif command == "/code":
454
+ current_mode = AgentMode.CODE
455
+ # Reset session so next chat creates a new session with code mode
456
+ if session_id:
457
+ session_id = None
458
+ console.print(f" [{Colors.SUCCESS}]{STATUS_ACTIVE}[/{Colors.SUCCESS}] [{Colors.SUCCESS}]code mode[/{Colors.SUCCESS}] [{Colors.DIM}](session reset)[/{Colors.DIM}]")
459
+ else:
460
+ console.print(f" [{Colors.SUCCESS}]{STATUS_ACTIVE}[/{Colors.SUCCESS}] [{Colors.SUCCESS}]code mode[/{Colors.SUCCESS}]")
461
+
462
+ elif command == "/mode":
463
+ mode_color = Colors.WARNING if current_mode == AgentMode.PLAN else Colors.SUCCESS
464
+ console.print(f" [{Colors.MUTED}]current mode:[/{Colors.MUTED}] [{mode_color}]{current_mode.value}[/{mode_color}]")
465
+
466
+ elif command == "/reset":
467
+ session_id = None
468
+ current_spec = None
469
+ console.print(f" [{Colors.DIM}]session reset[/{Colors.DIM}]")
470
+
471
+ elif command == "/spec":
472
+ if current_spec:
473
+ console.print(Panel(Markdown(current_spec), title="Current Spec"))
474
+ else:
475
+ console.print("[dim]No spec available. Use plan mode to create one.[/dim]")
476
+
477
+ elif command == "/pr":
478
+ handle_pr(args, run_slash_command_task, client, renderer, model, max_iterations)
479
+
480
+ elif command == "/projectmd":
481
+ handle_projectmd(run_slash_command_task, client, renderer, model, max_iterations)
482
+
483
+ elif command == "/research":
484
+ handle_research(args, run_slash_command_task, client, renderer, model)
485
+
486
+ elif command == "/status":
487
+ handle_status(client)
488
+
489
+ elif command == "/diff":
490
+ handle_diff(args)
491
+
492
+ elif command == "/agents":
493
+ handle_agents(args, client, renderer, model, max_iterations, render_with_interrupt)
494
+
495
+ elif command == "/todos":
496
+ handle_todos(args, client, session_id, pending_todos)
497
+
498
+ elif command == "/todo-add":
499
+ handle_todo_add(args, client, session_id, pending_todos)
500
+
501
+ elif command == "/session":
502
+ # Use list wrappers to allow mutation
503
+ session_id_ref = [session_id]
504
+ current_spec_ref = [current_spec]
505
+ current_mode_ref = [current_mode]
506
+ loaded_messages_ref = [loaded_messages]
507
+
508
+ handle_session(
509
+ args, client, model,
510
+ session_id_ref, current_spec_ref, current_mode_ref, loaded_messages_ref
511
+ )
512
+
513
+ # Update local variables from refs
514
+ session_id = session_id_ref[0]
515
+ current_spec = current_spec_ref[0]
516
+ current_mode = current_mode_ref[0]
517
+ loaded_messages[:] = loaded_messages_ref[0]
518
+
519
+ elif command == "/hooks":
520
+ handle_hooks(args)
521
+
522
+ elif command == "/rules":
523
+ handle_rules(args, client, renderer, model, max_iterations, render_with_interrupt)
524
+
525
+ elif command == "/skills":
526
+ handle_skills(args, client, renderer, model, max_iterations, render_with_interrupt)
527
+
528
+ elif command == "/index":
529
+ handle_index(args, client)
530
+
531
+ elif command == "/context":
532
+ handle_context(renderer)
533
+
534
+ elif command == "/paste" or command == "/image":
535
+ # Attach image from clipboard
536
+ from ...clipboard import get_clipboard_image
537
+ image_data = get_clipboard_image()
538
+ if image_data:
539
+ base64_data, img_format = image_data
540
+ attached_images.append({"data": base64_data, "format": img_format})
541
+ console.print(f" [{Colors.SUCCESS}]✓ Image {len(attached_images)} attached[/{Colors.SUCCESS}]")
542
+ else:
543
+ console.print(f" [{Colors.WARNING}]No image in clipboard[/{Colors.WARNING}]")
544
+ console.print(f" [{Colors.DIM}]Copy an image first (Cmd+Shift+4 for screenshot)[/{Colors.DIM}]")
545
+
546
+ elif command == "/compact":
547
+ handle_compact(client, session_id)
548
+
549
+ elif command == "/mcp":
550
+ handle_mcp(args)
551
+
552
+ elif command == "/registry":
553
+ handle_registry(args)
554
+
555
+ elif command == "/auth":
556
+ handle_auth(args)
557
+
558
+ elif command == "/doctor":
559
+ handle_doctor(args)
560
+
561
+ elif command == "/verify":
562
+ handle_verify(args, client, renderer, model, max_iterations, render_with_interrupt)
563
+
564
+ elif command == "/verify-loop":
565
+ if not args:
566
+ console.print("[yellow]Usage: /verify-loop <task description>[/yellow]")
567
+ console.print("[dim]Example: /verify-loop fix the failing tests[/dim]")
568
+ return True
569
+
570
+ # Create a task runner function that uses current client/renderer
571
+ def run_task(task_message: str):
572
+ nonlocal session_id # session_id is declared nonlocal in handle_slash_command
573
+ if session_id:
574
+ stream = client.agent_continue_stream(session_id, task_message)
575
+ else:
576
+ stream = client.agent_chat_stream(
577
+ message=task_message,
578
+ model=model,
579
+ max_iterations=max_iterations,
580
+ options={**options, "mode": current_mode.value},
581
+ )
582
+ result = render_with_interrupt(renderer, stream)
583
+ if result and result.get("session_id"):
584
+ session_id = result["session_id"]
585
+
586
+ handle_verify_loop(args, run_task)
587
+ return True
588
+
589
+ elif command == "/setup":
590
+ handle_setup(args, client, renderer, model)
591
+ return True
592
+
593
+ elif command == "/telegram":
594
+ handle_telegram(args)
595
+ return True
596
+
597
+ else:
598
+ console.print(f"[yellow]Unknown command: {command}[/yellow]")
599
+ console.print("[dim]Type /help for available commands[/dim]")
600
+
601
+ return True
602
+
603
+ # Check for first run and show onboarding
604
+ if is_first_run():
605
+ run_onboarding()
606
+
607
+ # Check for recent session to restore
608
+ recent_session = get_recent_session(client)
609
+ if recent_session:
610
+ choice, session_data = show_session_restore_prompt(recent_session)
611
+ if choice == "restore" and session_data:
612
+ session_id = session_data.get("name")
613
+ if session_data.get("mode"):
614
+ current_mode = AgentMode(session_data["mode"])
615
+ console.print(f" [{Colors.SUCCESS}]{STATUS_ACTIVE}[/{Colors.SUCCESS}] Session restored: {session_id}")
616
+ console.print()
617
+
618
+ # Show welcome message
619
+ from ... import __version__
620
+
621
+ # Get current working directory
622
+ cwd = Path.cwd()
623
+
624
+ # Get git repo name (if in a git repo)
625
+ git_repo = None
626
+ try:
627
+ result = subprocess.run(
628
+ ["git", "rev-parse", "--show-toplevel"],
629
+ capture_output=True, text=True, cwd=cwd
630
+ )
631
+ if result.returncode == 0:
632
+ git_repo = Path(result.stdout.strip()).name
633
+ except Exception:
634
+ pass
635
+
636
+ # Get current git branch
637
+ git_branch = None
638
+ try:
639
+ result = subprocess.run(
640
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
641
+ capture_output=True,
642
+ text=True,
643
+ timeout=5,
644
+ )
645
+ if result.returncode == 0:
646
+ git_branch = result.stdout.strip()
647
+ except Exception:
648
+ pass
649
+
650
+ # Get display model name
651
+ if model:
652
+ display_model = model
653
+ else:
654
+ from emdash_core.agent.providers.factory import DEFAULT_MODEL
655
+ display_model = DEFAULT_MODEL
656
+
657
+ # Shorten model name for display
658
+ if "/" in display_model:
659
+ display_model = display_model.split("/")[-1]
660
+
661
+ # Update toolbar variables for the bottom bar
662
+ toolbar_branch = git_branch
663
+ toolbar_model = display_model
664
+
665
+ # Welcome banner
666
+ show_welcome_banner(
667
+ version=__version__,
668
+ git_repo=git_repo,
669
+ git_branch=git_branch,
670
+ mode=current_mode.value,
671
+ model=display_model,
672
+ console=console,
673
+ )
674
+
675
+ while True:
676
+ try:
677
+ # Get user input
678
+ user_input = session.prompt(get_prompt()).strip()
679
+
680
+ if not user_input:
681
+ continue
682
+
683
+ # Check if input is an image file path (dragged file)
684
+ clean_input = user_input.replace("\\ ", " ")
685
+ if clean_input.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')):
686
+ from ...clipboard import get_image_from_path
687
+ image_data = get_image_from_path(clean_input)
688
+ if image_data:
689
+ base64_data, img_format = image_data
690
+ attached_images.append({"data": base64_data, "format": img_format})
691
+ continue # Prompt again for actual message
692
+
693
+ # Handle slash commands
694
+ if user_input.startswith("/"):
695
+ if not handle_slash_command(user_input):
696
+ break
697
+ continue
698
+
699
+ # Handle quit shortcuts
700
+ if user_input.lower() in ("quit", "exit", "q"):
701
+ break
702
+
703
+ # Expand @file references in the message
704
+ expanded_input, included_files = expand_file_references(user_input)
705
+ if included_files:
706
+ console.print(f"[dim]Including {len(included_files)} file(s): {', '.join(Path(f).name for f in included_files)}[/dim]")
707
+
708
+ # Build options with current mode
709
+ request_options = {
710
+ **options,
711
+ "mode": current_mode.value,
712
+ }
713
+
714
+ # Run agent with current mode
715
+ try:
716
+ # Prepare images for API call
717
+ images_to_send = attached_images if attached_images else None
718
+
719
+ if session_id:
720
+ stream = client.agent_continue_stream(
721
+ session_id, expanded_input, images=images_to_send
722
+ )
723
+ else:
724
+ # Pass loaded_messages from saved session if available
725
+ stream = client.agent_chat_stream(
726
+ message=expanded_input,
727
+ model=model,
728
+ max_iterations=max_iterations,
729
+ options=request_options,
730
+ images=images_to_send,
731
+ history=loaded_messages if loaded_messages else None,
732
+ )
733
+ # Clear loaded_messages after first use
734
+ loaded_messages.clear()
735
+
736
+ # Clear attached images after sending
737
+ attached_images = []
738
+
739
+ # Render the stream and capture any spec output
740
+ result = render_with_interrupt(renderer, stream)
741
+
742
+ # Check if we got a session ID back
743
+ if result and result.get("session_id"):
744
+ session_id = result["session_id"]
745
+
746
+ # Add any pending todos now that we have a session
747
+ if pending_todos:
748
+ for todo_title in pending_todos:
749
+ try:
750
+ client.add_todo(session_id, todo_title)
751
+ except Exception:
752
+ pass # Silently ignore errors adding todos
753
+ pending_todos.clear()
754
+
755
+ # Check for spec output
756
+ if result and result.get("spec"):
757
+ current_spec = result["spec"]
758
+
759
+ # Handle clarifications (may be chained - loop until no more)
760
+ while True:
761
+ clarification = result.get("clarification")
762
+ if not (clarification and session_id):
763
+ break
764
+
765
+ response = get_clarification_response(clarification)
766
+ if not response:
767
+ break
768
+
769
+ # Show the user's selection in the chat
770
+ console.print()
771
+ console.print(f"[dim]Selected:[/dim] [bold]{response}[/bold]")
772
+ console.print()
773
+
774
+ # Use dedicated clarification answer endpoint
775
+ try:
776
+ stream = client.clarification_answer_stream(session_id, response)
777
+ result = render_with_interrupt(renderer, stream)
778
+
779
+ # Update mode if user chose code
780
+ if "code" in response.lower():
781
+ current_mode = AgentMode.CODE
782
+ except Exception as e:
783
+ console.print(f"[red]Error continuing session: {e}[/red]")
784
+ break
785
+
786
+ # Handle plan mode entry request (show approval menu)
787
+ plan_mode_requested = result.get("plan_mode_requested")
788
+ if plan_mode_requested is not None and session_id:
789
+ choice, feedback = show_plan_mode_approval_menu()
790
+
791
+ if choice == "approve":
792
+ current_mode = AgentMode.PLAN
793
+ console.print()
794
+ console.print(f" [{Colors.SUCCESS}]{STATUS_ACTIVE}[/{Colors.SUCCESS}] [{Colors.WARNING}]plan mode activated[/{Colors.WARNING}]")
795
+ console.print()
796
+ # Use the planmode approve endpoint
797
+ stream = client.planmode_approve_stream(session_id)
798
+ result = render_with_interrupt(renderer, stream)
799
+ # After approval, check if there's now a plan submitted
800
+ if result.get("plan_submitted"):
801
+ pass # plan_submitted will be handled below
802
+ elif choice == "feedback":
803
+ # Use the planmode reject endpoint - stay in code mode
804
+ stream = client.planmode_reject_stream(session_id, feedback)
805
+ render_with_interrupt(renderer, stream)
806
+
807
+ # Handle plan mode completion (show approval menu)
808
+ # Only show menu when agent explicitly submits a plan via exit_plan tool
809
+ plan_submitted = result.get("plan_submitted")
810
+ should_show_plan_menu = (
811
+ current_mode == AgentMode.PLAN and
812
+ session_id and
813
+ plan_submitted is not None # Agent called exit_plan tool
814
+ )
815
+ if should_show_plan_menu:
816
+ choice, feedback = show_plan_approval_menu()
817
+
818
+ if choice == "approve":
819
+ current_mode = AgentMode.CODE
820
+ # Use the plan approve endpoint which properly resets mode on server
821
+ stream = client.plan_approve_stream(session_id)
822
+ render_with_interrupt(renderer, stream)
823
+ elif choice == "feedback":
824
+ if feedback:
825
+ # Use the plan reject endpoint which keeps mode as PLAN on server
826
+ stream = client.plan_reject_stream(session_id, feedback)
827
+ render_with_interrupt(renderer, stream)
828
+ else:
829
+ console.print("[dim]No feedback provided[/dim]")
830
+ session_id = None
831
+ current_spec = None
832
+
833
+ console.print()
834
+
835
+ except Exception as e:
836
+ console.print(f"[red]Error: {e}[/red]")
837
+
838
+ except KeyboardInterrupt:
839
+ console.print("\n[dim]Interrupted[/dim]")
840
+ break
841
+ except EOFError:
842
+ break