emdash-cli 0.1.17__py3-none-any.whl → 0.1.30__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/client.py +42 -20
- emdash_cli/clipboard.py +123 -0
- emdash_cli/commands/__init__.py +2 -0
- emdash_cli/commands/agent.py +193 -236
- emdash_cli/commands/skills.py +337 -0
- emdash_cli/keyboard.py +146 -0
- emdash_cli/main.py +5 -1
- emdash_cli/sse_renderer.py +124 -11
- {emdash_cli-0.1.17.dist-info → emdash_cli-0.1.30.dist-info}/METADATA +4 -2
- {emdash_cli-0.1.17.dist-info → emdash_cli-0.1.30.dist-info}/RECORD +12 -9
- {emdash_cli-0.1.17.dist-info → emdash_cli-0.1.30.dist-info}/WHEEL +0 -0
- {emdash_cli-0.1.17.dist-info → emdash_cli-0.1.30.dist-info}/entry_points.txt +0 -0
emdash_cli/commands/agent.py
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"""Agent CLI commands."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
|
|
3
6
|
import click
|
|
4
7
|
from enum import Enum
|
|
5
8
|
from rich.console import Console
|
|
@@ -7,6 +10,7 @@ from rich.panel import Panel
|
|
|
7
10
|
from rich.markdown import Markdown
|
|
8
11
|
|
|
9
12
|
from ..client import EmdashClient
|
|
13
|
+
from ..keyboard import KeyListener
|
|
10
14
|
from ..server_manager import get_server_manager
|
|
11
15
|
from ..sse_renderer import SSERenderer
|
|
12
16
|
|
|
@@ -16,15 +20,13 @@ console = Console()
|
|
|
16
20
|
class AgentMode(Enum):
|
|
17
21
|
"""Agent operation modes."""
|
|
18
22
|
PLAN = "plan"
|
|
19
|
-
TASKS = "tasks"
|
|
20
23
|
CODE = "code"
|
|
21
24
|
|
|
22
25
|
|
|
23
26
|
# Slash commands available in interactive mode
|
|
24
27
|
SLASH_COMMANDS = {
|
|
25
28
|
# Mode switching
|
|
26
|
-
"/plan": "Switch to plan mode (explore codebase, create
|
|
27
|
-
"/tasks": "Switch to tasks mode (generate task lists)",
|
|
29
|
+
"/plan": "Switch to plan mode (explore codebase, create plans)",
|
|
28
30
|
"/code": "Switch to code mode (execute file changes)",
|
|
29
31
|
"/mode": "Show current mode",
|
|
30
32
|
# Generation commands
|
|
@@ -51,10 +53,10 @@ def agent():
|
|
|
51
53
|
@agent.command("code")
|
|
52
54
|
@click.argument("task", required=False)
|
|
53
55
|
@click.option("--model", "-m", default=None, help="Model to use")
|
|
54
|
-
@click.option("--mode", type=click.Choice(["plan", "
|
|
56
|
+
@click.option("--mode", type=click.Choice(["plan", "code"]), default="code",
|
|
55
57
|
help="Starting mode")
|
|
56
58
|
@click.option("--quiet", "-q", is_flag=True, help="Less verbose output")
|
|
57
|
-
@click.option("--max-iterations", default=
|
|
59
|
+
@click.option("--max-iterations", default=int(os.getenv("EMDASH_MAX_ITERATIONS", "100")), help="Max agent iterations")
|
|
58
60
|
@click.option("--no-graph-tools", is_flag=True, help="Skip graph exploration tools")
|
|
59
61
|
@click.option("--save", is_flag=True, help="Save specs to specs/<feature>/")
|
|
60
62
|
def agent_code(
|
|
@@ -72,13 +74,11 @@ def agent_code(
|
|
|
72
74
|
Without TASK: Start interactive REPL mode
|
|
73
75
|
|
|
74
76
|
MODES:
|
|
75
|
-
plan - Explore codebase and create
|
|
76
|
-
tasks - Generate implementation task lists
|
|
77
|
+
plan - Explore codebase and create plans (read-only)
|
|
77
78
|
code - Execute code changes (default)
|
|
78
79
|
|
|
79
80
|
SLASH COMMANDS (in interactive mode):
|
|
80
81
|
/plan - Switch to plan mode
|
|
81
|
-
/tasks - Switch to tasks mode
|
|
82
82
|
/code - Switch to code mode
|
|
83
83
|
/help - Show available commands
|
|
84
84
|
/reset - Reset session
|
|
@@ -111,7 +111,7 @@ def agent_code(
|
|
|
111
111
|
|
|
112
112
|
|
|
113
113
|
def _get_clarification_response(clarification: dict) -> str | None:
|
|
114
|
-
"""Get user response for clarification with
|
|
114
|
+
"""Get user response for clarification with interactive selection.
|
|
115
115
|
|
|
116
116
|
Args:
|
|
117
117
|
clarification: Dict with question, context, and options
|
|
@@ -119,49 +119,22 @@ def _get_clarification_response(clarification: dict) -> str | None:
|
|
|
119
119
|
Returns:
|
|
120
120
|
User's selected option or typed response, or None if cancelled
|
|
121
121
|
"""
|
|
122
|
-
from prompt_toolkit import PromptSession
|
|
123
|
-
|
|
124
|
-
options = clarification.get("options", [])
|
|
125
|
-
|
|
126
|
-
session = PromptSession()
|
|
127
|
-
console.print("[dim]Enter number or type response:[/dim]")
|
|
128
|
-
|
|
129
|
-
try:
|
|
130
|
-
response = session.prompt("choice > ").strip()
|
|
131
|
-
|
|
132
|
-
if not response:
|
|
133
|
-
return None
|
|
134
|
-
|
|
135
|
-
# Map number to option
|
|
136
|
-
if response.isdigit():
|
|
137
|
-
idx = int(response) - 1
|
|
138
|
-
if 0 <= idx < len(options):
|
|
139
|
-
return options[idx]
|
|
140
|
-
|
|
141
|
-
return response
|
|
142
|
-
except (KeyboardInterrupt, EOFError):
|
|
143
|
-
return None
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def _show_spec_approval_menu() -> tuple[str, str]:
|
|
147
|
-
"""Show spec approval menu with arrow-key selection.
|
|
148
|
-
|
|
149
|
-
Returns:
|
|
150
|
-
Tuple of (choice, feedback) where feedback is only set for 'refine'
|
|
151
|
-
"""
|
|
152
|
-
from prompt_toolkit import Application
|
|
122
|
+
from prompt_toolkit import Application, PromptSession
|
|
153
123
|
from prompt_toolkit.key_binding import KeyBindings
|
|
154
124
|
from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
|
|
155
125
|
from prompt_toolkit.styles import Style
|
|
156
126
|
|
|
157
|
-
options = [
|
|
158
|
-
("tasks", "Generate implementation tasks"),
|
|
159
|
-
("code", "Start coding directly"),
|
|
160
|
-
("refine", "Provide feedback to improve"),
|
|
161
|
-
("abort", "Cancel and discard"),
|
|
162
|
-
]
|
|
127
|
+
options = clarification.get("options", [])
|
|
163
128
|
|
|
164
|
-
|
|
129
|
+
if not options:
|
|
130
|
+
# No options, just get free-form input
|
|
131
|
+
session = PromptSession()
|
|
132
|
+
try:
|
|
133
|
+
return session.prompt("response > ").strip() or None
|
|
134
|
+
except (KeyboardInterrupt, EOFError):
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
selected_index = [0]
|
|
165
138
|
result = [None]
|
|
166
139
|
|
|
167
140
|
# Key bindings
|
|
@@ -179,64 +152,53 @@ def _show_spec_approval_menu() -> tuple[str, str]:
|
|
|
179
152
|
|
|
180
153
|
@kb.add("enter")
|
|
181
154
|
def select(event):
|
|
182
|
-
result[0] = options[selected_index[0]]
|
|
155
|
+
result[0] = options[selected_index[0]]
|
|
183
156
|
event.app.exit()
|
|
184
157
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
event
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def select_2(event):
|
|
192
|
-
result[0] = "code"
|
|
193
|
-
event.app.exit()
|
|
194
|
-
|
|
195
|
-
@kb.add("3")
|
|
196
|
-
def select_3(event):
|
|
197
|
-
result[0] = "refine"
|
|
198
|
-
event.app.exit()
|
|
199
|
-
|
|
200
|
-
@kb.add("4")
|
|
201
|
-
def select_4(event):
|
|
202
|
-
result[0] = "abort"
|
|
203
|
-
event.app.exit()
|
|
158
|
+
# Number key shortcuts (1-9)
|
|
159
|
+
for i in range(min(9, len(options))):
|
|
160
|
+
@kb.add(str(i + 1))
|
|
161
|
+
def select_by_number(event, idx=i):
|
|
162
|
+
result[0] = options[idx]
|
|
163
|
+
event.app.exit()
|
|
204
164
|
|
|
205
165
|
@kb.add("c-c")
|
|
206
|
-
@kb.add("q")
|
|
207
166
|
@kb.add("escape")
|
|
208
167
|
def cancel(event):
|
|
209
|
-
result[0] =
|
|
168
|
+
result[0] = None
|
|
169
|
+
event.app.exit()
|
|
170
|
+
|
|
171
|
+
@kb.add("o") # 'o' for Other - custom input
|
|
172
|
+
def other_input(event):
|
|
173
|
+
result[0] = "OTHER_INPUT"
|
|
210
174
|
event.app.exit()
|
|
211
175
|
|
|
212
176
|
def get_formatted_options():
|
|
213
|
-
lines = [
|
|
214
|
-
for i,
|
|
177
|
+
lines = []
|
|
178
|
+
for i, opt in enumerate(options):
|
|
215
179
|
if i == selected_index[0]:
|
|
216
|
-
lines.append(("class:selected", f" ❯ {
|
|
217
|
-
lines.append(("class:selected-desc", f"- {desc}\n"))
|
|
180
|
+
lines.append(("class:selected", f" ❯ [{i+1}] {opt}\n"))
|
|
218
181
|
else:
|
|
219
|
-
lines.append(("class:option", f" {
|
|
220
|
-
|
|
221
|
-
lines.append(("class:hint", "\n↑/↓ to move, Enter to select, q to cancel"))
|
|
182
|
+
lines.append(("class:option", f" [{i+1}] {opt}\n"))
|
|
183
|
+
lines.append(("class:hint", "\n↑/↓ to move, Enter to select, 1-9 for quick select, o for other"))
|
|
222
184
|
return lines
|
|
223
185
|
|
|
224
186
|
# Style
|
|
225
187
|
style = Style.from_dict({
|
|
226
|
-
"title": "#00ccff bold",
|
|
227
188
|
"selected": "#00cc66 bold",
|
|
228
|
-
"selected-desc": "#00cc66",
|
|
229
189
|
"option": "#888888",
|
|
230
|
-
"desc": "#666666",
|
|
231
190
|
"hint": "#444444 italic",
|
|
232
191
|
})
|
|
233
192
|
|
|
193
|
+
# Calculate height based on options
|
|
194
|
+
height = len(options) + 2 # options + hint line + padding
|
|
195
|
+
|
|
234
196
|
# Layout
|
|
235
197
|
layout = Layout(
|
|
236
198
|
HSplit([
|
|
237
199
|
Window(
|
|
238
200
|
FormattedTextControl(get_formatted_options),
|
|
239
|
-
height=
|
|
201
|
+
height=height,
|
|
240
202
|
),
|
|
241
203
|
])
|
|
242
204
|
)
|
|
@@ -254,30 +216,48 @@ def _show_spec_approval_menu() -> tuple[str, str]:
|
|
|
254
216
|
try:
|
|
255
217
|
app.run()
|
|
256
218
|
except (KeyboardInterrupt, EOFError):
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
choice = result[0] or "abort"
|
|
219
|
+
return None
|
|
260
220
|
|
|
261
|
-
#
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
from prompt_toolkit import PromptSession
|
|
221
|
+
# Handle "other" option - get custom input
|
|
222
|
+
if result[0] == "OTHER_INPUT":
|
|
223
|
+
session = PromptSession()
|
|
265
224
|
console.print()
|
|
266
|
-
console.print("[dim]What changes would you like?[/dim]")
|
|
267
225
|
try:
|
|
268
|
-
session
|
|
269
|
-
feedback = session.prompt("feedback > ").strip()
|
|
226
|
+
return session.prompt("response > ").strip() or None
|
|
270
227
|
except (KeyboardInterrupt, EOFError):
|
|
271
|
-
return
|
|
228
|
+
return None
|
|
272
229
|
|
|
273
|
-
|
|
230
|
+
# Check if selected option is an "other/explain" type that needs text input
|
|
231
|
+
if result[0]:
|
|
232
|
+
lower_result = result[0].lower()
|
|
233
|
+
needs_input = any(phrase in lower_result for phrase in [
|
|
234
|
+
"something else",
|
|
235
|
+
"other",
|
|
236
|
+
"i'll explain",
|
|
237
|
+
"i will explain",
|
|
238
|
+
"let me explain",
|
|
239
|
+
"custom",
|
|
240
|
+
"none of the above",
|
|
241
|
+
])
|
|
242
|
+
if needs_input:
|
|
243
|
+
session = PromptSession()
|
|
244
|
+
console.print()
|
|
245
|
+
console.print("[dim]Please explain:[/dim]")
|
|
246
|
+
try:
|
|
247
|
+
custom_input = session.prompt("response > ").strip()
|
|
248
|
+
if custom_input:
|
|
249
|
+
return custom_input
|
|
250
|
+
except (KeyboardInterrupt, EOFError):
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
return result[0]
|
|
274
254
|
|
|
275
255
|
|
|
276
|
-
def
|
|
277
|
-
"""Show
|
|
256
|
+
def _show_plan_approval_menu() -> tuple[str, str]:
|
|
257
|
+
"""Show plan approval menu with simple approve/reject options.
|
|
278
258
|
|
|
279
259
|
Returns:
|
|
280
|
-
Tuple of (choice, feedback) where feedback is only set for '
|
|
260
|
+
Tuple of (choice, feedback) where feedback is only set for 'reject'
|
|
281
261
|
"""
|
|
282
262
|
from prompt_toolkit import Application
|
|
283
263
|
from prompt_toolkit.key_binding import KeyBindings
|
|
@@ -285,10 +265,8 @@ def _show_tasks_approval_menu() -> tuple[str, str]:
|
|
|
285
265
|
from prompt_toolkit.styles import Style
|
|
286
266
|
|
|
287
267
|
options = [
|
|
288
|
-
("
|
|
289
|
-
("
|
|
290
|
-
("export", "Export tasks to file"),
|
|
291
|
-
("abort", "Cancel and discard"),
|
|
268
|
+
("approve", "Approve and start implementation"),
|
|
269
|
+
("reject", "Reject and provide feedback"),
|
|
292
270
|
]
|
|
293
271
|
|
|
294
272
|
selected_index = [0] # Use list to allow mutation in closure
|
|
@@ -313,34 +291,26 @@ def _show_tasks_approval_menu() -> tuple[str, str]:
|
|
|
313
291
|
event.app.exit()
|
|
314
292
|
|
|
315
293
|
@kb.add("1")
|
|
316
|
-
|
|
317
|
-
|
|
294
|
+
@kb.add("y")
|
|
295
|
+
def select_approve(event):
|
|
296
|
+
result[0] = "approve"
|
|
318
297
|
event.app.exit()
|
|
319
298
|
|
|
320
299
|
@kb.add("2")
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
@kb.add("3")
|
|
326
|
-
def select_3(event):
|
|
327
|
-
result[0] = "export"
|
|
328
|
-
event.app.exit()
|
|
329
|
-
|
|
330
|
-
@kb.add("4")
|
|
331
|
-
def select_4(event):
|
|
332
|
-
result[0] = "abort"
|
|
300
|
+
@kb.add("n")
|
|
301
|
+
def select_reject(event):
|
|
302
|
+
result[0] = "reject"
|
|
333
303
|
event.app.exit()
|
|
334
304
|
|
|
335
305
|
@kb.add("c-c")
|
|
336
306
|
@kb.add("q")
|
|
337
307
|
@kb.add("escape")
|
|
338
308
|
def cancel(event):
|
|
339
|
-
result[0] = "
|
|
309
|
+
result[0] = "reject"
|
|
340
310
|
event.app.exit()
|
|
341
311
|
|
|
342
312
|
def get_formatted_options():
|
|
343
|
-
lines = [("class:title", "
|
|
313
|
+
lines = [("class:title", "Approve this plan?\n\n")]
|
|
344
314
|
for i, (key, desc) in enumerate(options):
|
|
345
315
|
if i == selected_index[0]:
|
|
346
316
|
lines.append(("class:selected", f" ❯ {key:8} "))
|
|
@@ -348,14 +318,14 @@ def _show_tasks_approval_menu() -> tuple[str, str]:
|
|
|
348
318
|
else:
|
|
349
319
|
lines.append(("class:option", f" {key:8} "))
|
|
350
320
|
lines.append(("class:desc", f"- {desc}\n"))
|
|
351
|
-
lines.append(("class:hint", "\n↑/↓ to move, Enter to select,
|
|
321
|
+
lines.append(("class:hint", "\n↑/↓ to move, Enter to select, y/n for quick select"))
|
|
352
322
|
return lines
|
|
353
323
|
|
|
354
324
|
# Style
|
|
355
325
|
style = Style.from_dict({
|
|
356
|
-
"title": "#
|
|
357
|
-
"selected": "#
|
|
358
|
-
"selected-desc": "#
|
|
326
|
+
"title": "#00ccff bold",
|
|
327
|
+
"selected": "#00cc66 bold",
|
|
328
|
+
"selected-desc": "#00cc66",
|
|
359
329
|
"option": "#888888",
|
|
360
330
|
"desc": "#666666",
|
|
361
331
|
"hint": "#444444 italic",
|
|
@@ -366,7 +336,7 @@ def _show_tasks_approval_menu() -> tuple[str, str]:
|
|
|
366
336
|
HSplit([
|
|
367
337
|
Window(
|
|
368
338
|
FormattedTextControl(get_formatted_options),
|
|
369
|
-
height=
|
|
339
|
+
height=6,
|
|
370
340
|
),
|
|
371
341
|
])
|
|
372
342
|
)
|
|
@@ -384,25 +354,50 @@ def _show_tasks_approval_menu() -> tuple[str, str]:
|
|
|
384
354
|
try:
|
|
385
355
|
app.run()
|
|
386
356
|
except (KeyboardInterrupt, EOFError):
|
|
387
|
-
result[0] = "
|
|
357
|
+
result[0] = "reject"
|
|
388
358
|
|
|
389
|
-
choice = result[0] or "
|
|
359
|
+
choice = result[0] or "reject"
|
|
390
360
|
|
|
391
|
-
# Get feedback if
|
|
361
|
+
# Get feedback if reject was chosen
|
|
392
362
|
feedback = ""
|
|
393
|
-
if choice == "
|
|
363
|
+
if choice == "reject":
|
|
394
364
|
from prompt_toolkit import PromptSession
|
|
395
365
|
console.print()
|
|
396
|
-
console.print("[dim]What changes would you like
|
|
366
|
+
console.print("[dim]What changes would you like?[/dim]")
|
|
397
367
|
try:
|
|
398
368
|
session = PromptSession()
|
|
399
369
|
feedback = session.prompt("feedback > ").strip()
|
|
400
370
|
except (KeyboardInterrupt, EOFError):
|
|
401
|
-
return "
|
|
371
|
+
return "reject", ""
|
|
402
372
|
|
|
403
373
|
return choice, feedback
|
|
404
374
|
|
|
405
375
|
|
|
376
|
+
def _render_with_interrupt(renderer: SSERenderer, stream) -> dict:
|
|
377
|
+
"""Render stream with ESC key interrupt support.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
renderer: SSE renderer instance
|
|
381
|
+
stream: SSE stream iterator
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
Result dict from renderer, with 'interrupted' flag
|
|
385
|
+
"""
|
|
386
|
+
interrupt_event = threading.Event()
|
|
387
|
+
|
|
388
|
+
def on_escape():
|
|
389
|
+
interrupt_event.set()
|
|
390
|
+
|
|
391
|
+
listener = KeyListener(on_escape)
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
listener.start()
|
|
395
|
+
result = renderer.render_stream(stream, interrupt_event=interrupt_event)
|
|
396
|
+
return result
|
|
397
|
+
finally:
|
|
398
|
+
listener.stop()
|
|
399
|
+
|
|
400
|
+
|
|
406
401
|
def _run_single_task(
|
|
407
402
|
client: EmdashClient,
|
|
408
403
|
renderer: SSERenderer,
|
|
@@ -419,7 +414,9 @@ def _run_single_task(
|
|
|
419
414
|
max_iterations=max_iterations,
|
|
420
415
|
options=options,
|
|
421
416
|
)
|
|
422
|
-
renderer
|
|
417
|
+
result = _render_with_interrupt(renderer, stream)
|
|
418
|
+
if result.get("interrupted"):
|
|
419
|
+
console.print("[dim]Task interrupted. You can continue or start a new task.[/dim]")
|
|
423
420
|
except Exception as e:
|
|
424
421
|
console.print(f"[red]Error: {e}[/red]")
|
|
425
422
|
raise click.Abort()
|
|
@@ -441,7 +438,9 @@ def _run_slash_command_task(
|
|
|
441
438
|
max_iterations=max_iterations,
|
|
442
439
|
options=options,
|
|
443
440
|
)
|
|
444
|
-
renderer
|
|
441
|
+
result = _render_with_interrupt(renderer, stream)
|
|
442
|
+
if result.get("interrupted"):
|
|
443
|
+
console.print("[dim]Task interrupted.[/dim]")
|
|
445
444
|
console.print()
|
|
446
445
|
except Exception as e:
|
|
447
446
|
console.print(f"[red]Error: {e}[/red]")
|
|
@@ -466,13 +465,15 @@ def _run_interactive(
|
|
|
466
465
|
current_mode = AgentMode(options.get("mode", "code"))
|
|
467
466
|
session_id = None
|
|
468
467
|
current_spec = None
|
|
468
|
+
# Attached images for next message
|
|
469
|
+
attached_images: list[dict] = []
|
|
469
470
|
|
|
470
471
|
# Style for prompt
|
|
471
472
|
PROMPT_STYLE = Style.from_dict({
|
|
472
473
|
"prompt.mode.plan": "#ffcc00 bold",
|
|
473
|
-
"prompt.mode.tasks": "#cc66ff bold",
|
|
474
474
|
"prompt.mode.code": "#00cc66 bold",
|
|
475
475
|
"prompt.prefix": "#888888",
|
|
476
|
+
"prompt.image": "#00ccff",
|
|
476
477
|
"completion-menu": "bg:#1a1a2e #ffffff",
|
|
477
478
|
"completion-menu.completion": "bg:#1a1a2e #ffffff",
|
|
478
479
|
"completion-menu.completion.current": "bg:#4a4a6e #ffffff bold",
|
|
@@ -519,6 +520,22 @@ def _run_interactive(
|
|
|
519
520
|
"""Insert a newline character with Alt+Enter or Ctrl+J."""
|
|
520
521
|
event.current_buffer.insert_text("\n")
|
|
521
522
|
|
|
523
|
+
@kb.add("c-v") # Ctrl+V to paste (check for images)
|
|
524
|
+
def paste_with_image_check(event):
|
|
525
|
+
"""Paste text or attach image from clipboard."""
|
|
526
|
+
nonlocal attached_images
|
|
527
|
+
from ..clipboard import get_clipboard_image
|
|
528
|
+
|
|
529
|
+
# Try to get image from clipboard
|
|
530
|
+
image_data = get_clipboard_image()
|
|
531
|
+
if image_data:
|
|
532
|
+
base64_data, img_format = image_data
|
|
533
|
+
attached_images.append({"data": base64_data, "format": img_format})
|
|
534
|
+
console.print(f"[green]📎 Image attached[/green] [dim]({img_format})[/dim]")
|
|
535
|
+
else:
|
|
536
|
+
# No image, do normal paste
|
|
537
|
+
event.current_buffer.paste_clipboard_data(event.app.clipboard.get_data())
|
|
538
|
+
|
|
522
539
|
session = PromptSession(
|
|
523
540
|
history=history,
|
|
524
541
|
completer=SlashCommandCompleter(),
|
|
@@ -530,15 +547,14 @@ def _run_interactive(
|
|
|
530
547
|
)
|
|
531
548
|
|
|
532
549
|
def get_prompt():
|
|
533
|
-
"""Get formatted prompt
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
return [(color_class, f"[{mode_name}]"), ("", " "), ("class:prompt.prefix", "> ")]
|
|
550
|
+
"""Get formatted prompt."""
|
|
551
|
+
nonlocal attached_images
|
|
552
|
+
parts = []
|
|
553
|
+
# Add image indicator if images attached
|
|
554
|
+
if attached_images:
|
|
555
|
+
parts.append(("class:prompt.image", f"📎{len(attached_images)} "))
|
|
556
|
+
parts.append(("class:prompt.prefix", "> "))
|
|
557
|
+
return parts
|
|
542
558
|
|
|
543
559
|
def show_help():
|
|
544
560
|
"""Show available commands."""
|
|
@@ -569,10 +585,6 @@ def _run_interactive(
|
|
|
569
585
|
current_mode = AgentMode.PLAN
|
|
570
586
|
console.print("[yellow]Switched to plan mode[/yellow]")
|
|
571
587
|
|
|
572
|
-
elif command == "/tasks":
|
|
573
|
-
current_mode = AgentMode.TASKS
|
|
574
|
-
console.print("[magenta]Switched to tasks mode[/magenta]")
|
|
575
|
-
|
|
576
588
|
elif command == "/code":
|
|
577
589
|
current_mode = AgentMode.CODE
|
|
578
590
|
console.print("[green]Switched to code mode[/green]")
|
|
@@ -702,14 +714,13 @@ def _run_interactive(
|
|
|
702
714
|
except Exception:
|
|
703
715
|
pass
|
|
704
716
|
|
|
717
|
+
# Welcome banner
|
|
705
718
|
console.print()
|
|
706
|
-
console.print(f"[bold cyan]
|
|
707
|
-
console.print(f"Mode: [bold]{current_mode.value}[/bold] | Model: [dim]{model or 'default'}[/dim]")
|
|
719
|
+
console.print(f"[bold cyan]Emdash Code[/bold cyan] [dim]v{__version__}[/dim]")
|
|
708
720
|
if git_repo:
|
|
709
|
-
console.print(f"Repo: [bold green]{git_repo}[/bold green] |
|
|
721
|
+
console.print(f"[dim]Repo:[/dim] [bold green]{git_repo}[/bold green] [dim]| Mode:[/dim] [bold]{current_mode.value}[/bold] [dim]| Model:[/dim] {model or 'default'}")
|
|
710
722
|
else:
|
|
711
|
-
console.print(f"
|
|
712
|
-
console.print("Type your task or [cyan]/help[/cyan] for commands. Use Ctrl+C to exit.")
|
|
723
|
+
console.print(f"[dim]Mode:[/dim] [bold]{current_mode.value}[/bold] [dim]| Model:[/dim] {model or 'default'}")
|
|
713
724
|
console.print()
|
|
714
725
|
|
|
715
726
|
while True:
|
|
@@ -738,18 +749,27 @@ def _run_interactive(
|
|
|
738
749
|
|
|
739
750
|
# Run agent with current mode
|
|
740
751
|
try:
|
|
752
|
+
# Prepare images for API call
|
|
753
|
+
images_to_send = attached_images if attached_images else None
|
|
754
|
+
|
|
741
755
|
if session_id:
|
|
742
|
-
stream = client.agent_continue_stream(
|
|
756
|
+
stream = client.agent_continue_stream(
|
|
757
|
+
session_id, user_input, images=images_to_send
|
|
758
|
+
)
|
|
743
759
|
else:
|
|
744
760
|
stream = client.agent_chat_stream(
|
|
745
761
|
message=user_input,
|
|
746
762
|
model=model,
|
|
747
763
|
max_iterations=max_iterations,
|
|
748
764
|
options=request_options,
|
|
765
|
+
images=images_to_send,
|
|
749
766
|
)
|
|
750
767
|
|
|
768
|
+
# Clear attached images after sending
|
|
769
|
+
attached_images = []
|
|
770
|
+
|
|
751
771
|
# Render the stream and capture any spec output
|
|
752
|
-
result = renderer
|
|
772
|
+
result = _render_with_interrupt(renderer, stream)
|
|
753
773
|
|
|
754
774
|
# Check if we got a session ID back
|
|
755
775
|
if result and result.get("session_id"):
|
|
@@ -766,108 +786,45 @@ def _run_interactive(
|
|
|
766
786
|
if response:
|
|
767
787
|
# Continue session with user's choice
|
|
768
788
|
stream = client.agent_continue_stream(session_id, response)
|
|
769
|
-
result = renderer
|
|
789
|
+
result = _render_with_interrupt(renderer, stream)
|
|
770
790
|
|
|
771
|
-
# Update mode if user chose
|
|
772
|
-
if "
|
|
773
|
-
current_mode = AgentMode.TASKS
|
|
774
|
-
elif "code" in response.lower():
|
|
791
|
+
# Update mode if user chose code
|
|
792
|
+
if "code" in response.lower():
|
|
775
793
|
current_mode = AgentMode.CODE
|
|
776
794
|
|
|
777
795
|
# Handle plan mode completion (show approval menu)
|
|
778
|
-
#
|
|
796
|
+
# Only show menu when agent explicitly submits a plan via exit_plan tool
|
|
779
797
|
content = result.get("content", "")
|
|
798
|
+
plan_submitted = result.get("plan_submitted")
|
|
780
799
|
should_show_plan_menu = (
|
|
781
800
|
current_mode == AgentMode.PLAN and
|
|
782
801
|
session_id and
|
|
783
|
-
|
|
802
|
+
plan_submitted is not None # Agent called exit_plan tool
|
|
784
803
|
)
|
|
785
804
|
if should_show_plan_menu:
|
|
786
|
-
choice, feedback =
|
|
787
|
-
|
|
788
|
-
if choice == "tasks":
|
|
789
|
-
current_mode = AgentMode.TASKS
|
|
790
|
-
# Include the spec content explicitly in the message
|
|
791
|
-
tasks_prompt = f"""Generate implementation tasks from this approved specification.
|
|
792
|
-
|
|
793
|
-
## Approved Specification
|
|
794
|
-
|
|
795
|
-
{content}
|
|
796
|
-
|
|
797
|
-
## Your Task
|
|
798
|
-
|
|
799
|
-
Use your tools to explore the codebase and create implementation tasks:
|
|
800
|
-
|
|
801
|
-
1. **Explore**: Use semantic_search and code graph tools to find:
|
|
802
|
-
- Existing related code to modify
|
|
803
|
-
- Patterns and conventions used in the codebase
|
|
804
|
-
- Files that will need changes
|
|
805
|
-
|
|
806
|
-
2. **Generate Tasks**: Create detailed tasks that include:
|
|
807
|
-
- Task ID (T1, T2, T3...)
|
|
808
|
-
- Description of what to implement
|
|
809
|
-
- Specific files to modify (based on your exploration)
|
|
810
|
-
- Dependencies on other tasks
|
|
811
|
-
- Complexity estimate (S/M/L)
|
|
812
|
-
- Acceptance criteria
|
|
813
|
-
|
|
814
|
-
3. **Order**: Arrange tasks in implementation order, starting with foundational changes.
|
|
815
|
-
|
|
816
|
-
Output a comprehensive task list that a developer can follow step-by-step."""
|
|
817
|
-
stream = client.agent_continue_stream(
|
|
818
|
-
session_id,
|
|
819
|
-
tasks_prompt
|
|
820
|
-
)
|
|
821
|
-
result = renderer.render_stream(stream)
|
|
822
|
-
# After generating tasks, show tasks menu
|
|
823
|
-
content = result.get("content", "")
|
|
824
|
-
elif choice == "code":
|
|
825
|
-
current_mode = AgentMode.CODE
|
|
826
|
-
stream = client.agent_continue_stream(
|
|
827
|
-
session_id,
|
|
828
|
-
"Start implementing the approved spec."
|
|
829
|
-
)
|
|
830
|
-
renderer.render_stream(stream)
|
|
831
|
-
elif choice == "refine":
|
|
832
|
-
stream = client.agent_continue_stream(
|
|
833
|
-
session_id,
|
|
834
|
-
f"Please update the spec based on this feedback: {feedback}"
|
|
835
|
-
)
|
|
836
|
-
renderer.render_stream(stream)
|
|
837
|
-
elif choice == "abort":
|
|
838
|
-
console.print("[dim]Spec discarded[/dim]")
|
|
839
|
-
session_id = None
|
|
840
|
-
current_spec = None
|
|
841
|
-
|
|
842
|
-
# Handle tasks mode completion (show tasks approval menu)
|
|
843
|
-
should_show_tasks_menu = (
|
|
844
|
-
current_mode == AgentMode.TASKS and
|
|
845
|
-
session_id and
|
|
846
|
-
len(content) > 100 # Has substantial content
|
|
847
|
-
)
|
|
848
|
-
if should_show_tasks_menu:
|
|
849
|
-
choice, feedback = _show_tasks_approval_menu()
|
|
805
|
+
choice, feedback = _show_plan_approval_menu()
|
|
850
806
|
|
|
851
|
-
if choice == "
|
|
807
|
+
if choice == "approve":
|
|
852
808
|
current_mode = AgentMode.CODE
|
|
809
|
+
# Reset mode state to CODE
|
|
810
|
+
from emdash_core.agent.tools.modes import ModeState, AgentMode as CoreMode
|
|
811
|
+
ModeState.get_instance().current_mode = CoreMode.CODE
|
|
853
812
|
stream = client.agent_continue_stream(
|
|
854
813
|
session_id,
|
|
855
|
-
"
|
|
856
|
-
)
|
|
857
|
-
renderer.render_stream(stream)
|
|
858
|
-
elif choice == "refine":
|
|
859
|
-
stream = client.agent_continue_stream(
|
|
860
|
-
session_id,
|
|
861
|
-
f"Please update the tasks based on this feedback: {feedback}"
|
|
814
|
+
"The plan has been approved. Start implementing it now."
|
|
862
815
|
)
|
|
863
|
-
renderer
|
|
864
|
-
elif choice == "
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
816
|
+
_render_with_interrupt(renderer, stream)
|
|
817
|
+
elif choice == "reject":
|
|
818
|
+
if feedback:
|
|
819
|
+
stream = client.agent_continue_stream(
|
|
820
|
+
session_id,
|
|
821
|
+
f"The plan was rejected. Please revise based on this feedback: {feedback}"
|
|
822
|
+
)
|
|
823
|
+
_render_with_interrupt(renderer, stream)
|
|
824
|
+
else:
|
|
825
|
+
console.print("[dim]Plan rejected[/dim]")
|
|
826
|
+
session_id = None
|
|
827
|
+
current_spec = None
|
|
871
828
|
|
|
872
829
|
console.print()
|
|
873
830
|
|