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.
- emdash_cli/__init__.py +15 -0
- emdash_cli/client.py +156 -0
- emdash_cli/clipboard.py +30 -61
- emdash_cli/commands/agent/__init__.py +14 -0
- emdash_cli/commands/agent/cli.py +100 -0
- emdash_cli/commands/agent/constants.py +53 -0
- emdash_cli/commands/agent/file_utils.py +178 -0
- emdash_cli/commands/agent/handlers/__init__.py +41 -0
- emdash_cli/commands/agent/handlers/agents.py +421 -0
- emdash_cli/commands/agent/handlers/auth.py +69 -0
- emdash_cli/commands/agent/handlers/doctor.py +319 -0
- emdash_cli/commands/agent/handlers/hooks.py +121 -0
- emdash_cli/commands/agent/handlers/mcp.py +183 -0
- emdash_cli/commands/agent/handlers/misc.py +200 -0
- emdash_cli/commands/agent/handlers/rules.py +394 -0
- emdash_cli/commands/agent/handlers/sessions.py +168 -0
- emdash_cli/commands/agent/handlers/setup.py +582 -0
- emdash_cli/commands/agent/handlers/skills.py +440 -0
- emdash_cli/commands/agent/handlers/todos.py +98 -0
- emdash_cli/commands/agent/handlers/verify.py +648 -0
- emdash_cli/commands/agent/interactive.py +657 -0
- emdash_cli/commands/agent/menus.py +728 -0
- emdash_cli/commands/agent.py +7 -856
- emdash_cli/commands/server.py +99 -40
- emdash_cli/server_manager.py +70 -10
- emdash_cli/session_store.py +321 -0
- emdash_cli/sse_renderer.py +256 -110
- {emdash_cli-0.1.30.dist-info → emdash_cli-0.1.46.dist-info}/METADATA +2 -4
- emdash_cli-0.1.46.dist-info/RECORD +49 -0
- emdash_cli-0.1.30.dist-info/RECORD +0 -29
- {emdash_cli-0.1.30.dist-info → emdash_cli-0.1.46.dist-info}/WHEEL +0 -0
- {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
|