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
emdash_cli/commands/agent.py
CHANGED
|
@@ -1,859 +1,10 @@
|
|
|
1
|
-
"""Agent CLI commands.
|
|
1
|
+
"""Agent CLI commands - backward compatibility module.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
This file re-exports from the refactored agent/ package for backward compatibility.
|
|
4
|
+
The actual implementation is now in agent/ subdirectory.
|
|
5
|
+
"""
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
from
|
|
8
|
-
from rich.console import Console
|
|
9
|
-
from rich.panel import Panel
|
|
10
|
-
from rich.markdown import Markdown
|
|
7
|
+
# Re-export the agent click group and commands
|
|
8
|
+
from .agent import agent, agent_code
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
from ..keyboard import KeyListener
|
|
14
|
-
from ..server_manager import get_server_manager
|
|
15
|
-
from ..sse_renderer import SSERenderer
|
|
16
|
-
|
|
17
|
-
console = Console()
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class AgentMode(Enum):
|
|
21
|
-
"""Agent operation modes."""
|
|
22
|
-
PLAN = "plan"
|
|
23
|
-
CODE = "code"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
# Slash commands available in interactive mode
|
|
27
|
-
SLASH_COMMANDS = {
|
|
28
|
-
# Mode switching
|
|
29
|
-
"/plan": "Switch to plan mode (explore codebase, create plans)",
|
|
30
|
-
"/code": "Switch to code mode (execute file changes)",
|
|
31
|
-
"/mode": "Show current mode",
|
|
32
|
-
# Generation commands
|
|
33
|
-
"/pr [url]": "Review a pull request",
|
|
34
|
-
"/projectmd": "Generate PROJECT.md for the codebase",
|
|
35
|
-
"/research [goal]": "Deep research on a topic",
|
|
36
|
-
# Status commands
|
|
37
|
-
"/status": "Show index and PROJECT.md status",
|
|
38
|
-
# Session management
|
|
39
|
-
"/spec": "Show current specification",
|
|
40
|
-
"/reset": "Reset session state",
|
|
41
|
-
"/save": "Save current spec to disk",
|
|
42
|
-
"/help": "Show available commands",
|
|
43
|
-
"/quit": "Exit the agent",
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
@click.group()
|
|
48
|
-
def agent():
|
|
49
|
-
"""AI agent commands."""
|
|
50
|
-
pass
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
@agent.command("code")
|
|
54
|
-
@click.argument("task", required=False)
|
|
55
|
-
@click.option("--model", "-m", default=None, help="Model to use")
|
|
56
|
-
@click.option("--mode", type=click.Choice(["plan", "code"]), default="code",
|
|
57
|
-
help="Starting mode")
|
|
58
|
-
@click.option("--quiet", "-q", is_flag=True, help="Less verbose output")
|
|
59
|
-
@click.option("--max-iterations", default=int(os.getenv("EMDASH_MAX_ITERATIONS", "100")), help="Max agent iterations")
|
|
60
|
-
@click.option("--no-graph-tools", is_flag=True, help="Skip graph exploration tools")
|
|
61
|
-
@click.option("--save", is_flag=True, help="Save specs to specs/<feature>/")
|
|
62
|
-
def agent_code(
|
|
63
|
-
task: str | None,
|
|
64
|
-
model: str | None,
|
|
65
|
-
mode: str,
|
|
66
|
-
quiet: bool,
|
|
67
|
-
max_iterations: int,
|
|
68
|
-
no_graph_tools: bool,
|
|
69
|
-
save: bool,
|
|
70
|
-
):
|
|
71
|
-
"""Start the coding agent.
|
|
72
|
-
|
|
73
|
-
With TASK: Run single task and exit
|
|
74
|
-
Without TASK: Start interactive REPL mode
|
|
75
|
-
|
|
76
|
-
MODES:
|
|
77
|
-
plan - Explore codebase and create plans (read-only)
|
|
78
|
-
code - Execute code changes (default)
|
|
79
|
-
|
|
80
|
-
SLASH COMMANDS (in interactive mode):
|
|
81
|
-
/plan - Switch to plan mode
|
|
82
|
-
/code - Switch to code mode
|
|
83
|
-
/help - Show available commands
|
|
84
|
-
/reset - Reset session
|
|
85
|
-
|
|
86
|
-
Examples:
|
|
87
|
-
emdash # Interactive code mode
|
|
88
|
-
emdash agent code # Same as above
|
|
89
|
-
emdash agent code --mode plan # Start in plan mode
|
|
90
|
-
emdash agent code "Fix the login bug" # Single task
|
|
91
|
-
"""
|
|
92
|
-
# Get server URL (starts server if needed)
|
|
93
|
-
server = get_server_manager()
|
|
94
|
-
base_url = server.get_server_url()
|
|
95
|
-
|
|
96
|
-
client = EmdashClient(base_url)
|
|
97
|
-
renderer = SSERenderer(console=console, verbose=not quiet)
|
|
98
|
-
|
|
99
|
-
options = {
|
|
100
|
-
"mode": mode,
|
|
101
|
-
"no_graph_tools": no_graph_tools,
|
|
102
|
-
"save": save,
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if task:
|
|
106
|
-
# Single task mode
|
|
107
|
-
_run_single_task(client, renderer, task, model, max_iterations, options)
|
|
108
|
-
else:
|
|
109
|
-
# Interactive REPL mode
|
|
110
|
-
_run_interactive(client, renderer, model, max_iterations, options)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def _get_clarification_response(clarification: dict) -> str | None:
|
|
114
|
-
"""Get user response for clarification with interactive selection.
|
|
115
|
-
|
|
116
|
-
Args:
|
|
117
|
-
clarification: Dict with question, context, and options
|
|
118
|
-
|
|
119
|
-
Returns:
|
|
120
|
-
User's selected option or typed response, or None if cancelled
|
|
121
|
-
"""
|
|
122
|
-
from prompt_toolkit import Application, PromptSession
|
|
123
|
-
from prompt_toolkit.key_binding import KeyBindings
|
|
124
|
-
from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
|
|
125
|
-
from prompt_toolkit.styles import Style
|
|
126
|
-
|
|
127
|
-
options = clarification.get("options", [])
|
|
128
|
-
|
|
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]
|
|
138
|
-
result = [None]
|
|
139
|
-
|
|
140
|
-
# Key bindings
|
|
141
|
-
kb = KeyBindings()
|
|
142
|
-
|
|
143
|
-
@kb.add("up")
|
|
144
|
-
@kb.add("k")
|
|
145
|
-
def move_up(event):
|
|
146
|
-
selected_index[0] = (selected_index[0] - 1) % len(options)
|
|
147
|
-
|
|
148
|
-
@kb.add("down")
|
|
149
|
-
@kb.add("j")
|
|
150
|
-
def move_down(event):
|
|
151
|
-
selected_index[0] = (selected_index[0] + 1) % len(options)
|
|
152
|
-
|
|
153
|
-
@kb.add("enter")
|
|
154
|
-
def select(event):
|
|
155
|
-
result[0] = options[selected_index[0]]
|
|
156
|
-
event.app.exit()
|
|
157
|
-
|
|
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()
|
|
164
|
-
|
|
165
|
-
@kb.add("c-c")
|
|
166
|
-
@kb.add("escape")
|
|
167
|
-
def cancel(event):
|
|
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"
|
|
174
|
-
event.app.exit()
|
|
175
|
-
|
|
176
|
-
def get_formatted_options():
|
|
177
|
-
lines = []
|
|
178
|
-
for i, opt in enumerate(options):
|
|
179
|
-
if i == selected_index[0]:
|
|
180
|
-
lines.append(("class:selected", f" ❯ [{i+1}] {opt}\n"))
|
|
181
|
-
else:
|
|
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"))
|
|
184
|
-
return lines
|
|
185
|
-
|
|
186
|
-
# Style
|
|
187
|
-
style = Style.from_dict({
|
|
188
|
-
"selected": "#00cc66 bold",
|
|
189
|
-
"option": "#888888",
|
|
190
|
-
"hint": "#444444 italic",
|
|
191
|
-
})
|
|
192
|
-
|
|
193
|
-
# Calculate height based on options
|
|
194
|
-
height = len(options) + 2 # options + hint line + padding
|
|
195
|
-
|
|
196
|
-
# Layout
|
|
197
|
-
layout = Layout(
|
|
198
|
-
HSplit([
|
|
199
|
-
Window(
|
|
200
|
-
FormattedTextControl(get_formatted_options),
|
|
201
|
-
height=height,
|
|
202
|
-
),
|
|
203
|
-
])
|
|
204
|
-
)
|
|
205
|
-
|
|
206
|
-
# Application
|
|
207
|
-
app = Application(
|
|
208
|
-
layout=layout,
|
|
209
|
-
key_bindings=kb,
|
|
210
|
-
style=style,
|
|
211
|
-
full_screen=False,
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
console.print()
|
|
215
|
-
|
|
216
|
-
try:
|
|
217
|
-
app.run()
|
|
218
|
-
except (KeyboardInterrupt, EOFError):
|
|
219
|
-
return None
|
|
220
|
-
|
|
221
|
-
# Handle "other" option - get custom input
|
|
222
|
-
if result[0] == "OTHER_INPUT":
|
|
223
|
-
session = PromptSession()
|
|
224
|
-
console.print()
|
|
225
|
-
try:
|
|
226
|
-
return session.prompt("response > ").strip() or None
|
|
227
|
-
except (KeyboardInterrupt, EOFError):
|
|
228
|
-
return None
|
|
229
|
-
|
|
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]
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
def _show_plan_approval_menu() -> tuple[str, str]:
|
|
257
|
-
"""Show plan approval menu with simple approve/reject options.
|
|
258
|
-
|
|
259
|
-
Returns:
|
|
260
|
-
Tuple of (choice, feedback) where feedback is only set for 'reject'
|
|
261
|
-
"""
|
|
262
|
-
from prompt_toolkit import Application
|
|
263
|
-
from prompt_toolkit.key_binding import KeyBindings
|
|
264
|
-
from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
|
|
265
|
-
from prompt_toolkit.styles import Style
|
|
266
|
-
|
|
267
|
-
options = [
|
|
268
|
-
("approve", "Approve and start implementation"),
|
|
269
|
-
("reject", "Reject and provide feedback"),
|
|
270
|
-
]
|
|
271
|
-
|
|
272
|
-
selected_index = [0] # Use list to allow mutation in closure
|
|
273
|
-
result = [None]
|
|
274
|
-
|
|
275
|
-
# Key bindings
|
|
276
|
-
kb = KeyBindings()
|
|
277
|
-
|
|
278
|
-
@kb.add("up")
|
|
279
|
-
@kb.add("k")
|
|
280
|
-
def move_up(event):
|
|
281
|
-
selected_index[0] = (selected_index[0] - 1) % len(options)
|
|
282
|
-
|
|
283
|
-
@kb.add("down")
|
|
284
|
-
@kb.add("j")
|
|
285
|
-
def move_down(event):
|
|
286
|
-
selected_index[0] = (selected_index[0] + 1) % len(options)
|
|
287
|
-
|
|
288
|
-
@kb.add("enter")
|
|
289
|
-
def select(event):
|
|
290
|
-
result[0] = options[selected_index[0]][0]
|
|
291
|
-
event.app.exit()
|
|
292
|
-
|
|
293
|
-
@kb.add("1")
|
|
294
|
-
@kb.add("y")
|
|
295
|
-
def select_approve(event):
|
|
296
|
-
result[0] = "approve"
|
|
297
|
-
event.app.exit()
|
|
298
|
-
|
|
299
|
-
@kb.add("2")
|
|
300
|
-
@kb.add("n")
|
|
301
|
-
def select_reject(event):
|
|
302
|
-
result[0] = "reject"
|
|
303
|
-
event.app.exit()
|
|
304
|
-
|
|
305
|
-
@kb.add("c-c")
|
|
306
|
-
@kb.add("q")
|
|
307
|
-
@kb.add("escape")
|
|
308
|
-
def cancel(event):
|
|
309
|
-
result[0] = "reject"
|
|
310
|
-
event.app.exit()
|
|
311
|
-
|
|
312
|
-
def get_formatted_options():
|
|
313
|
-
lines = [("class:title", "Approve this plan?\n\n")]
|
|
314
|
-
for i, (key, desc) in enumerate(options):
|
|
315
|
-
if i == selected_index[0]:
|
|
316
|
-
lines.append(("class:selected", f" ❯ {key:8} "))
|
|
317
|
-
lines.append(("class:selected-desc", f"- {desc}\n"))
|
|
318
|
-
else:
|
|
319
|
-
lines.append(("class:option", f" {key:8} "))
|
|
320
|
-
lines.append(("class:desc", f"- {desc}\n"))
|
|
321
|
-
lines.append(("class:hint", "\n↑/↓ to move, Enter to select, y/n for quick select"))
|
|
322
|
-
return lines
|
|
323
|
-
|
|
324
|
-
# Style
|
|
325
|
-
style = Style.from_dict({
|
|
326
|
-
"title": "#00ccff bold",
|
|
327
|
-
"selected": "#00cc66 bold",
|
|
328
|
-
"selected-desc": "#00cc66",
|
|
329
|
-
"option": "#888888",
|
|
330
|
-
"desc": "#666666",
|
|
331
|
-
"hint": "#444444 italic",
|
|
332
|
-
})
|
|
333
|
-
|
|
334
|
-
# Layout
|
|
335
|
-
layout = Layout(
|
|
336
|
-
HSplit([
|
|
337
|
-
Window(
|
|
338
|
-
FormattedTextControl(get_formatted_options),
|
|
339
|
-
height=6,
|
|
340
|
-
),
|
|
341
|
-
])
|
|
342
|
-
)
|
|
343
|
-
|
|
344
|
-
# Application
|
|
345
|
-
app = Application(
|
|
346
|
-
layout=layout,
|
|
347
|
-
key_bindings=kb,
|
|
348
|
-
style=style,
|
|
349
|
-
full_screen=False,
|
|
350
|
-
)
|
|
351
|
-
|
|
352
|
-
console.print()
|
|
353
|
-
|
|
354
|
-
try:
|
|
355
|
-
app.run()
|
|
356
|
-
except (KeyboardInterrupt, EOFError):
|
|
357
|
-
result[0] = "reject"
|
|
358
|
-
|
|
359
|
-
choice = result[0] or "reject"
|
|
360
|
-
|
|
361
|
-
# Get feedback if reject was chosen
|
|
362
|
-
feedback = ""
|
|
363
|
-
if choice == "reject":
|
|
364
|
-
from prompt_toolkit import PromptSession
|
|
365
|
-
console.print()
|
|
366
|
-
console.print("[dim]What changes would you like?[/dim]")
|
|
367
|
-
try:
|
|
368
|
-
session = PromptSession()
|
|
369
|
-
feedback = session.prompt("feedback > ").strip()
|
|
370
|
-
except (KeyboardInterrupt, EOFError):
|
|
371
|
-
return "reject", ""
|
|
372
|
-
|
|
373
|
-
return choice, feedback
|
|
374
|
-
|
|
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
|
-
|
|
401
|
-
def _run_single_task(
|
|
402
|
-
client: EmdashClient,
|
|
403
|
-
renderer: SSERenderer,
|
|
404
|
-
task: str,
|
|
405
|
-
model: str | None,
|
|
406
|
-
max_iterations: int,
|
|
407
|
-
options: dict,
|
|
408
|
-
):
|
|
409
|
-
"""Run a single agent task."""
|
|
410
|
-
try:
|
|
411
|
-
stream = client.agent_chat_stream(
|
|
412
|
-
message=task,
|
|
413
|
-
model=model,
|
|
414
|
-
max_iterations=max_iterations,
|
|
415
|
-
options=options,
|
|
416
|
-
)
|
|
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]")
|
|
420
|
-
except Exception as e:
|
|
421
|
-
console.print(f"[red]Error: {e}[/red]")
|
|
422
|
-
raise click.Abort()
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
def _run_slash_command_task(
|
|
426
|
-
client: EmdashClient,
|
|
427
|
-
renderer: SSERenderer,
|
|
428
|
-
model: str | None,
|
|
429
|
-
max_iterations: int,
|
|
430
|
-
task: str,
|
|
431
|
-
options: dict,
|
|
432
|
-
):
|
|
433
|
-
"""Run a task from a slash command."""
|
|
434
|
-
try:
|
|
435
|
-
stream = client.agent_chat_stream(
|
|
436
|
-
message=task,
|
|
437
|
-
model=model,
|
|
438
|
-
max_iterations=max_iterations,
|
|
439
|
-
options=options,
|
|
440
|
-
)
|
|
441
|
-
result = _render_with_interrupt(renderer, stream)
|
|
442
|
-
if result.get("interrupted"):
|
|
443
|
-
console.print("[dim]Task interrupted.[/dim]")
|
|
444
|
-
console.print()
|
|
445
|
-
except Exception as e:
|
|
446
|
-
console.print(f"[red]Error: {e}[/red]")
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
def _run_interactive(
|
|
450
|
-
client: EmdashClient,
|
|
451
|
-
renderer: SSERenderer,
|
|
452
|
-
model: str | None,
|
|
453
|
-
max_iterations: int,
|
|
454
|
-
options: dict,
|
|
455
|
-
):
|
|
456
|
-
"""Run interactive REPL mode with slash commands."""
|
|
457
|
-
from prompt_toolkit import PromptSession
|
|
458
|
-
from prompt_toolkit.history import FileHistory
|
|
459
|
-
from prompt_toolkit.completion import Completer, Completion
|
|
460
|
-
from prompt_toolkit.styles import Style
|
|
461
|
-
from prompt_toolkit.key_binding import KeyBindings
|
|
462
|
-
from pathlib import Path
|
|
463
|
-
|
|
464
|
-
# Current mode
|
|
465
|
-
current_mode = AgentMode(options.get("mode", "code"))
|
|
466
|
-
session_id = None
|
|
467
|
-
current_spec = None
|
|
468
|
-
# Attached images for next message
|
|
469
|
-
attached_images: list[dict] = []
|
|
470
|
-
|
|
471
|
-
# Style for prompt
|
|
472
|
-
PROMPT_STYLE = Style.from_dict({
|
|
473
|
-
"prompt.mode.plan": "#ffcc00 bold",
|
|
474
|
-
"prompt.mode.code": "#00cc66 bold",
|
|
475
|
-
"prompt.prefix": "#888888",
|
|
476
|
-
"prompt.image": "#00ccff",
|
|
477
|
-
"completion-menu": "bg:#1a1a2e #ffffff",
|
|
478
|
-
"completion-menu.completion": "bg:#1a1a2e #ffffff",
|
|
479
|
-
"completion-menu.completion.current": "bg:#4a4a6e #ffffff bold",
|
|
480
|
-
"completion-menu.meta.completion": "bg:#1a1a2e #888888",
|
|
481
|
-
"completion-menu.meta.completion.current": "bg:#4a4a6e #aaaaaa",
|
|
482
|
-
"command": "#00ccff bold",
|
|
483
|
-
})
|
|
484
|
-
|
|
485
|
-
class SlashCommandCompleter(Completer):
|
|
486
|
-
"""Completer for slash commands."""
|
|
487
|
-
|
|
488
|
-
def get_completions(self, document, complete_event):
|
|
489
|
-
text = document.text_before_cursor
|
|
490
|
-
if not text.startswith("/"):
|
|
491
|
-
return
|
|
492
|
-
for cmd, description in SLASH_COMMANDS.items():
|
|
493
|
-
# Extract base command (e.g., "/pr" from "/pr [url]")
|
|
494
|
-
base_cmd = cmd.split()[0]
|
|
495
|
-
if base_cmd.startswith(text):
|
|
496
|
-
yield Completion(
|
|
497
|
-
base_cmd,
|
|
498
|
-
start_position=-len(text),
|
|
499
|
-
display=cmd,
|
|
500
|
-
display_meta=description,
|
|
501
|
-
)
|
|
502
|
-
|
|
503
|
-
# Setup history file
|
|
504
|
-
history_file = Path.home() / ".emdash" / "cli_history"
|
|
505
|
-
history_file.parent.mkdir(parents=True, exist_ok=True)
|
|
506
|
-
history = FileHistory(str(history_file))
|
|
507
|
-
|
|
508
|
-
# Key bindings: Enter submits, Alt+Enter inserts newline
|
|
509
|
-
# Note: Shift+Enter is indistinguishable from Enter in most terminals
|
|
510
|
-
kb = KeyBindings()
|
|
511
|
-
|
|
512
|
-
@kb.add("enter")
|
|
513
|
-
def submit_on_enter(event):
|
|
514
|
-
"""Submit on Enter."""
|
|
515
|
-
event.current_buffer.validate_and_handle()
|
|
516
|
-
|
|
517
|
-
@kb.add("escape", "enter") # Alt+Enter (Escape then Enter)
|
|
518
|
-
@kb.add("c-j") # Ctrl+J as alternative for newline
|
|
519
|
-
def insert_newline_alt(event):
|
|
520
|
-
"""Insert a newline character with Alt+Enter or Ctrl+J."""
|
|
521
|
-
event.current_buffer.insert_text("\n")
|
|
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
|
-
|
|
539
|
-
session = PromptSession(
|
|
540
|
-
history=history,
|
|
541
|
-
completer=SlashCommandCompleter(),
|
|
542
|
-
style=PROMPT_STYLE,
|
|
543
|
-
complete_while_typing=True,
|
|
544
|
-
multiline=True,
|
|
545
|
-
prompt_continuation="... ",
|
|
546
|
-
key_bindings=kb,
|
|
547
|
-
)
|
|
548
|
-
|
|
549
|
-
def get_prompt():
|
|
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
|
|
558
|
-
|
|
559
|
-
def show_help():
|
|
560
|
-
"""Show available commands."""
|
|
561
|
-
console.print()
|
|
562
|
-
console.print("[bold cyan]Available Commands[/bold cyan]")
|
|
563
|
-
console.print()
|
|
564
|
-
for cmd, desc in SLASH_COMMANDS.items():
|
|
565
|
-
console.print(f" [cyan]{cmd:12}[/cyan] {desc}")
|
|
566
|
-
console.print()
|
|
567
|
-
console.print("[dim]Type your task or question to interact with the agent.[/dim]")
|
|
568
|
-
console.print()
|
|
569
|
-
|
|
570
|
-
def handle_slash_command(cmd: str) -> bool:
|
|
571
|
-
"""Handle a slash command. Returns True if should continue, False to exit."""
|
|
572
|
-
nonlocal current_mode, session_id, current_spec
|
|
573
|
-
|
|
574
|
-
cmd_parts = cmd.strip().split(maxsplit=1)
|
|
575
|
-
command = cmd_parts[0].lower()
|
|
576
|
-
args = cmd_parts[1] if len(cmd_parts) > 1 else ""
|
|
577
|
-
|
|
578
|
-
if command == "/quit" or command == "/exit" or command == "/q":
|
|
579
|
-
return False
|
|
580
|
-
|
|
581
|
-
elif command == "/help":
|
|
582
|
-
show_help()
|
|
583
|
-
|
|
584
|
-
elif command == "/plan":
|
|
585
|
-
current_mode = AgentMode.PLAN
|
|
586
|
-
console.print("[yellow]Switched to plan mode[/yellow]")
|
|
587
|
-
|
|
588
|
-
elif command == "/code":
|
|
589
|
-
current_mode = AgentMode.CODE
|
|
590
|
-
console.print("[green]Switched to code mode[/green]")
|
|
591
|
-
|
|
592
|
-
elif command == "/mode":
|
|
593
|
-
console.print(f"Current mode: [bold]{current_mode.value}[/bold]")
|
|
594
|
-
|
|
595
|
-
elif command == "/reset":
|
|
596
|
-
session_id = None
|
|
597
|
-
current_spec = None
|
|
598
|
-
console.print("[dim]Session reset[/dim]")
|
|
599
|
-
|
|
600
|
-
elif command == "/spec":
|
|
601
|
-
if current_spec:
|
|
602
|
-
console.print(Panel(Markdown(current_spec), title="Current Spec"))
|
|
603
|
-
else:
|
|
604
|
-
console.print("[dim]No spec available. Use plan mode to create one.[/dim]")
|
|
605
|
-
|
|
606
|
-
elif command == "/save":
|
|
607
|
-
if current_spec:
|
|
608
|
-
# TODO: Save spec via API
|
|
609
|
-
console.print("[yellow]Save not implemented yet[/yellow]")
|
|
610
|
-
else:
|
|
611
|
-
console.print("[dim]No spec to save[/dim]")
|
|
612
|
-
|
|
613
|
-
elif command == "/pr":
|
|
614
|
-
# PR review
|
|
615
|
-
if not args:
|
|
616
|
-
console.print("[yellow]Usage: /pr <pr-url-or-number>[/yellow]")
|
|
617
|
-
console.print("[dim]Example: /pr 123 or /pr https://github.com/org/repo/pull/123[/dim]")
|
|
618
|
-
else:
|
|
619
|
-
console.print(f"[cyan]Reviewing PR: {args}[/cyan]")
|
|
620
|
-
_run_slash_command_task(
|
|
621
|
-
client, renderer, model, max_iterations,
|
|
622
|
-
f"Review this pull request and provide feedback: {args}",
|
|
623
|
-
{"mode": "code"}
|
|
624
|
-
)
|
|
625
|
-
|
|
626
|
-
elif command == "/projectmd":
|
|
627
|
-
# Generate PROJECT.md
|
|
628
|
-
console.print("[cyan]Generating PROJECT.md...[/cyan]")
|
|
629
|
-
_run_slash_command_task(
|
|
630
|
-
client, renderer, model, max_iterations,
|
|
631
|
-
"Analyze this codebase and generate a comprehensive PROJECT.md file that describes the architecture, main components, how to get started, and key design decisions.",
|
|
632
|
-
{"mode": "code"}
|
|
633
|
-
)
|
|
634
|
-
|
|
635
|
-
elif command == "/research":
|
|
636
|
-
# Deep research
|
|
637
|
-
if not args:
|
|
638
|
-
console.print("[yellow]Usage: /research <goal>[/yellow]")
|
|
639
|
-
console.print("[dim]Example: /research How does authentication work in this codebase?[/dim]")
|
|
640
|
-
else:
|
|
641
|
-
console.print(f"[cyan]Researching: {args}[/cyan]")
|
|
642
|
-
_run_slash_command_task(
|
|
643
|
-
client, renderer, model, 50, # More iterations for research
|
|
644
|
-
f"Conduct deep research on: {args}\n\nExplore the codebase thoroughly, analyze relevant code, and provide a comprehensive answer with references to specific files and functions.",
|
|
645
|
-
{"mode": "plan"} # Use plan mode for research
|
|
646
|
-
)
|
|
647
|
-
|
|
648
|
-
elif command == "/status":
|
|
649
|
-
# Show index and PROJECT.md status
|
|
650
|
-
from datetime import datetime
|
|
651
|
-
|
|
652
|
-
console.print("\n[bold cyan]Status[/bold cyan]\n")
|
|
653
|
-
|
|
654
|
-
# Index status
|
|
655
|
-
console.print("[bold]Index Status[/bold]")
|
|
656
|
-
try:
|
|
657
|
-
status = client.index_status(str(Path.cwd()))
|
|
658
|
-
is_indexed = status.get("is_indexed", False)
|
|
659
|
-
console.print(f" Indexed: {'[green]Yes[/green]' if is_indexed else '[yellow]No[/yellow]'}")
|
|
660
|
-
|
|
661
|
-
if is_indexed:
|
|
662
|
-
console.print(f" Files: {status.get('file_count', 0)}")
|
|
663
|
-
console.print(f" Functions: {status.get('function_count', 0)}")
|
|
664
|
-
console.print(f" Classes: {status.get('class_count', 0)}")
|
|
665
|
-
console.print(f" Communities: {status.get('community_count', 0)}")
|
|
666
|
-
if status.get("last_indexed"):
|
|
667
|
-
console.print(f" Last indexed: {status.get('last_indexed')}")
|
|
668
|
-
if status.get("last_commit"):
|
|
669
|
-
console.print(f" Last commit: {status.get('last_commit')}")
|
|
670
|
-
except Exception as e:
|
|
671
|
-
console.print(f" [red]Error fetching index status: {e}[/red]")
|
|
672
|
-
|
|
673
|
-
console.print()
|
|
674
|
-
|
|
675
|
-
# PROJECT.md status
|
|
676
|
-
console.print("[bold]PROJECT.md Status[/bold]")
|
|
677
|
-
projectmd_path = Path.cwd() / "PROJECT.md"
|
|
678
|
-
if projectmd_path.exists():
|
|
679
|
-
stat = projectmd_path.stat()
|
|
680
|
-
modified_time = datetime.fromtimestamp(stat.st_mtime)
|
|
681
|
-
size_kb = stat.st_size / 1024
|
|
682
|
-
console.print(f" Exists: [green]Yes[/green]")
|
|
683
|
-
console.print(f" Path: {projectmd_path}")
|
|
684
|
-
console.print(f" Size: {size_kb:.1f} KB")
|
|
685
|
-
console.print(f" Last modified: {modified_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
686
|
-
else:
|
|
687
|
-
console.print(f" Exists: [yellow]No[/yellow]")
|
|
688
|
-
console.print("[dim] Run /projectmd to generate it[/dim]")
|
|
689
|
-
|
|
690
|
-
console.print()
|
|
691
|
-
|
|
692
|
-
else:
|
|
693
|
-
console.print(f"[yellow]Unknown command: {command}[/yellow]")
|
|
694
|
-
console.print("[dim]Type /help for available commands[/dim]")
|
|
695
|
-
|
|
696
|
-
return True
|
|
697
|
-
|
|
698
|
-
# Show welcome message
|
|
699
|
-
from .. import __version__
|
|
700
|
-
import subprocess
|
|
701
|
-
|
|
702
|
-
# Get current working directory
|
|
703
|
-
cwd = Path.cwd()
|
|
704
|
-
|
|
705
|
-
# Get git repo name (if in a git repo)
|
|
706
|
-
git_repo = None
|
|
707
|
-
try:
|
|
708
|
-
result = subprocess.run(
|
|
709
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
710
|
-
capture_output=True, text=True, cwd=cwd
|
|
711
|
-
)
|
|
712
|
-
if result.returncode == 0:
|
|
713
|
-
git_repo = Path(result.stdout.strip()).name
|
|
714
|
-
except Exception:
|
|
715
|
-
pass
|
|
716
|
-
|
|
717
|
-
# Welcome banner
|
|
718
|
-
console.print()
|
|
719
|
-
console.print(f"[bold cyan]Emdash Code[/bold cyan] [dim]v{__version__}[/dim]")
|
|
720
|
-
if git_repo:
|
|
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'}")
|
|
722
|
-
else:
|
|
723
|
-
console.print(f"[dim]Mode:[/dim] [bold]{current_mode.value}[/bold] [dim]| Model:[/dim] {model or 'default'}")
|
|
724
|
-
console.print()
|
|
725
|
-
|
|
726
|
-
while True:
|
|
727
|
-
try:
|
|
728
|
-
# Get user input
|
|
729
|
-
user_input = session.prompt(get_prompt()).strip()
|
|
730
|
-
|
|
731
|
-
if not user_input:
|
|
732
|
-
continue
|
|
733
|
-
|
|
734
|
-
# Handle slash commands
|
|
735
|
-
if user_input.startswith("/"):
|
|
736
|
-
if not handle_slash_command(user_input):
|
|
737
|
-
break
|
|
738
|
-
continue
|
|
739
|
-
|
|
740
|
-
# Handle quit shortcuts
|
|
741
|
-
if user_input.lower() in ("quit", "exit", "q"):
|
|
742
|
-
break
|
|
743
|
-
|
|
744
|
-
# Build options with current mode
|
|
745
|
-
request_options = {
|
|
746
|
-
**options,
|
|
747
|
-
"mode": current_mode.value,
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
# Run agent with current mode
|
|
751
|
-
try:
|
|
752
|
-
# Prepare images for API call
|
|
753
|
-
images_to_send = attached_images if attached_images else None
|
|
754
|
-
|
|
755
|
-
if session_id:
|
|
756
|
-
stream = client.agent_continue_stream(
|
|
757
|
-
session_id, user_input, images=images_to_send
|
|
758
|
-
)
|
|
759
|
-
else:
|
|
760
|
-
stream = client.agent_chat_stream(
|
|
761
|
-
message=user_input,
|
|
762
|
-
model=model,
|
|
763
|
-
max_iterations=max_iterations,
|
|
764
|
-
options=request_options,
|
|
765
|
-
images=images_to_send,
|
|
766
|
-
)
|
|
767
|
-
|
|
768
|
-
# Clear attached images after sending
|
|
769
|
-
attached_images = []
|
|
770
|
-
|
|
771
|
-
# Render the stream and capture any spec output
|
|
772
|
-
result = _render_with_interrupt(renderer, stream)
|
|
773
|
-
|
|
774
|
-
# Check if we got a session ID back
|
|
775
|
-
if result and result.get("session_id"):
|
|
776
|
-
session_id = result["session_id"]
|
|
777
|
-
|
|
778
|
-
# Check for spec output
|
|
779
|
-
if result and result.get("spec"):
|
|
780
|
-
current_spec = result["spec"]
|
|
781
|
-
|
|
782
|
-
# Handle clarification with options (interactive selection)
|
|
783
|
-
clarification = result.get("clarification")
|
|
784
|
-
if clarification and clarification.get("options") and session_id:
|
|
785
|
-
response = _get_clarification_response(clarification)
|
|
786
|
-
if response:
|
|
787
|
-
# Continue session with user's choice
|
|
788
|
-
stream = client.agent_continue_stream(session_id, response)
|
|
789
|
-
result = _render_with_interrupt(renderer, stream)
|
|
790
|
-
|
|
791
|
-
# Update mode if user chose code
|
|
792
|
-
if "code" in response.lower():
|
|
793
|
-
current_mode = AgentMode.CODE
|
|
794
|
-
|
|
795
|
-
# Handle plan mode completion (show approval menu)
|
|
796
|
-
# Only show menu when agent explicitly submits a plan via exit_plan tool
|
|
797
|
-
content = result.get("content", "")
|
|
798
|
-
plan_submitted = result.get("plan_submitted")
|
|
799
|
-
should_show_plan_menu = (
|
|
800
|
-
current_mode == AgentMode.PLAN and
|
|
801
|
-
session_id and
|
|
802
|
-
plan_submitted is not None # Agent called exit_plan tool
|
|
803
|
-
)
|
|
804
|
-
if should_show_plan_menu:
|
|
805
|
-
choice, feedback = _show_plan_approval_menu()
|
|
806
|
-
|
|
807
|
-
if choice == "approve":
|
|
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
|
|
812
|
-
stream = client.agent_continue_stream(
|
|
813
|
-
session_id,
|
|
814
|
-
"The plan has been approved. Start implementing it now."
|
|
815
|
-
)
|
|
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
|
|
828
|
-
|
|
829
|
-
console.print()
|
|
830
|
-
|
|
831
|
-
except Exception as e:
|
|
832
|
-
console.print(f"[red]Error: {e}[/red]")
|
|
833
|
-
|
|
834
|
-
except KeyboardInterrupt:
|
|
835
|
-
console.print("\n[dim]Interrupted[/dim]")
|
|
836
|
-
break
|
|
837
|
-
except EOFError:
|
|
838
|
-
break
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
@agent.command("sessions")
|
|
842
|
-
def list_sessions():
|
|
843
|
-
"""List active agent sessions."""
|
|
844
|
-
server = get_server_manager()
|
|
845
|
-
base_url = server.get_server_url()
|
|
846
|
-
|
|
847
|
-
client = EmdashClient(base_url)
|
|
848
|
-
sessions = client.list_sessions()
|
|
849
|
-
|
|
850
|
-
if not sessions:
|
|
851
|
-
console.print("[dim]No active sessions[/dim]")
|
|
852
|
-
return
|
|
853
|
-
|
|
854
|
-
for s in sessions:
|
|
855
|
-
console.print(
|
|
856
|
-
f" {s['session_id'][:8]}... "
|
|
857
|
-
f"[dim]({s.get('model', 'unknown')}, "
|
|
858
|
-
f"{s.get('message_count', 0)} messages)[/dim]"
|
|
859
|
-
)
|
|
10
|
+
__all__ = ["agent", "agent_code"]
|