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.
- emdash_cli/client.py +41 -22
- emdash_cli/clipboard.py +30 -61
- emdash_cli/commands/__init__.py +2 -2
- emdash_cli/commands/agent/__init__.py +14 -0
- emdash_cli/commands/agent/cli.py +100 -0
- emdash_cli/commands/agent/constants.py +63 -0
- emdash_cli/commands/agent/file_utils.py +178 -0
- emdash_cli/commands/agent/handlers/__init__.py +51 -0
- emdash_cli/commands/agent/handlers/agents.py +449 -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/index.py +183 -0
- emdash_cli/commands/agent/handlers/mcp.py +183 -0
- emdash_cli/commands/agent/handlers/misc.py +319 -0
- emdash_cli/commands/agent/handlers/registry.py +72 -0
- emdash_cli/commands/agent/handlers/rules.py +411 -0
- emdash_cli/commands/agent/handlers/sessions.py +168 -0
- emdash_cli/commands/agent/handlers/setup.py +715 -0
- emdash_cli/commands/agent/handlers/skills.py +478 -0
- emdash_cli/commands/agent/handlers/telegram.py +475 -0
- emdash_cli/commands/agent/handlers/todos.py +119 -0
- emdash_cli/commands/agent/handlers/verify.py +653 -0
- emdash_cli/commands/agent/help.py +236 -0
- emdash_cli/commands/agent/interactive.py +842 -0
- emdash_cli/commands/agent/menus.py +760 -0
- emdash_cli/commands/agent/onboarding.py +619 -0
- emdash_cli/commands/agent/session_restore.py +210 -0
- emdash_cli/commands/agent.py +7 -1321
- emdash_cli/commands/index.py +111 -13
- emdash_cli/commands/registry.py +635 -0
- emdash_cli/commands/server.py +99 -40
- emdash_cli/commands/skills.py +72 -6
- emdash_cli/design.py +328 -0
- emdash_cli/diff_renderer.py +438 -0
- emdash_cli/integrations/__init__.py +1 -0
- emdash_cli/integrations/telegram/__init__.py +15 -0
- emdash_cli/integrations/telegram/bot.py +402 -0
- emdash_cli/integrations/telegram/bridge.py +865 -0
- emdash_cli/integrations/telegram/config.py +155 -0
- emdash_cli/integrations/telegram/formatter.py +385 -0
- emdash_cli/main.py +52 -2
- emdash_cli/server_manager.py +70 -10
- emdash_cli/sse_renderer.py +659 -167
- {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/METADATA +2 -4
- emdash_cli-0.1.67.dist-info/RECORD +63 -0
- emdash_cli/commands/swarm.py +0 -86
- emdash_cli-0.1.35.dist-info/RECORD +0 -30
- {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/WHEEL +0 -0
- {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/entry_points.txt +0 -0
emdash_cli/commands/agent.py
CHANGED
|
@@ -1,1324 +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
|
-
"/agents": "List, create, or show agents (e.g., /agents create my-agent)",
|
|
39
|
-
# Session management
|
|
40
|
-
"/session": "Save, load, or list sessions (e.g., /session save my-task)",
|
|
41
|
-
"/spec": "Show current specification",
|
|
42
|
-
"/reset": "Reset session state",
|
|
43
|
-
"/save": "Save current spec to disk",
|
|
44
|
-
"/help": "Show available commands",
|
|
45
|
-
"/quit": "Exit the agent",
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
@click.group()
|
|
50
|
-
def agent():
|
|
51
|
-
"""AI agent commands."""
|
|
52
|
-
pass
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
@agent.command("code")
|
|
56
|
-
@click.argument("task", required=False)
|
|
57
|
-
@click.option("--model", "-m", default=None, help="Model to use")
|
|
58
|
-
@click.option("--mode", type=click.Choice(["plan", "code"]), default="code",
|
|
59
|
-
help="Starting mode")
|
|
60
|
-
@click.option("--quiet", "-q", is_flag=True, help="Less verbose output")
|
|
61
|
-
@click.option("--max-iterations", default=int(os.getenv("EMDASH_MAX_ITERATIONS", "100")), help="Max agent iterations")
|
|
62
|
-
@click.option("--no-graph-tools", is_flag=True, help="Skip graph exploration tools")
|
|
63
|
-
@click.option("--save", is_flag=True, help="Save specs to specs/<feature>/")
|
|
64
|
-
def agent_code(
|
|
65
|
-
task: str | None,
|
|
66
|
-
model: str | None,
|
|
67
|
-
mode: str,
|
|
68
|
-
quiet: bool,
|
|
69
|
-
max_iterations: int,
|
|
70
|
-
no_graph_tools: bool,
|
|
71
|
-
save: bool,
|
|
72
|
-
):
|
|
73
|
-
"""Start the coding agent.
|
|
74
|
-
|
|
75
|
-
With TASK: Run single task and exit
|
|
76
|
-
Without TASK: Start interactive REPL mode
|
|
77
|
-
|
|
78
|
-
MODES:
|
|
79
|
-
plan - Explore codebase and create plans (read-only)
|
|
80
|
-
code - Execute code changes (default)
|
|
81
|
-
|
|
82
|
-
SLASH COMMANDS (in interactive mode):
|
|
83
|
-
/plan - Switch to plan mode
|
|
84
|
-
/code - Switch to code mode
|
|
85
|
-
/help - Show available commands
|
|
86
|
-
/reset - Reset session
|
|
87
|
-
|
|
88
|
-
Examples:
|
|
89
|
-
emdash # Interactive code mode
|
|
90
|
-
emdash agent code # Same as above
|
|
91
|
-
emdash agent code --mode plan # Start in plan mode
|
|
92
|
-
emdash agent code "Fix the login bug" # Single task
|
|
93
|
-
"""
|
|
94
|
-
# Get server URL (starts server if needed)
|
|
95
|
-
server = get_server_manager()
|
|
96
|
-
base_url = server.get_server_url()
|
|
97
|
-
|
|
98
|
-
client = EmdashClient(base_url)
|
|
99
|
-
renderer = SSERenderer(console=console, verbose=not quiet)
|
|
100
|
-
|
|
101
|
-
options = {
|
|
102
|
-
"mode": mode,
|
|
103
|
-
"no_graph_tools": no_graph_tools,
|
|
104
|
-
"save": save,
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if task:
|
|
108
|
-
# Single task mode
|
|
109
|
-
_run_single_task(client, renderer, task, model, max_iterations, options)
|
|
110
|
-
else:
|
|
111
|
-
# Interactive REPL mode
|
|
112
|
-
_run_interactive(client, renderer, model, max_iterations, options)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def _get_clarification_response(clarification: dict) -> str | None:
|
|
116
|
-
"""Get user response for clarification with interactive selection.
|
|
117
|
-
|
|
118
|
-
Args:
|
|
119
|
-
clarification: Dict with question, context, and options
|
|
120
|
-
|
|
121
|
-
Returns:
|
|
122
|
-
User's selected option or typed response, or None if cancelled
|
|
123
|
-
"""
|
|
124
|
-
from prompt_toolkit import Application, PromptSession
|
|
125
|
-
from prompt_toolkit.key_binding import KeyBindings
|
|
126
|
-
from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
|
|
127
|
-
from prompt_toolkit.styles import Style
|
|
128
|
-
|
|
129
|
-
options = clarification.get("options", [])
|
|
130
|
-
|
|
131
|
-
if not options:
|
|
132
|
-
# No options, just get free-form input
|
|
133
|
-
session = PromptSession()
|
|
134
|
-
try:
|
|
135
|
-
return session.prompt("response > ").strip() or None
|
|
136
|
-
except (KeyboardInterrupt, EOFError):
|
|
137
|
-
return None
|
|
138
|
-
|
|
139
|
-
selected_index = [0]
|
|
140
|
-
result = [None]
|
|
141
|
-
|
|
142
|
-
# Key bindings
|
|
143
|
-
kb = KeyBindings()
|
|
144
|
-
|
|
145
|
-
@kb.add("up")
|
|
146
|
-
@kb.add("k")
|
|
147
|
-
def move_up(event):
|
|
148
|
-
selected_index[0] = (selected_index[0] - 1) % len(options)
|
|
149
|
-
|
|
150
|
-
@kb.add("down")
|
|
151
|
-
@kb.add("j")
|
|
152
|
-
def move_down(event):
|
|
153
|
-
selected_index[0] = (selected_index[0] + 1) % len(options)
|
|
154
|
-
|
|
155
|
-
@kb.add("enter")
|
|
156
|
-
def select(event):
|
|
157
|
-
result[0] = options[selected_index[0]]
|
|
158
|
-
event.app.exit()
|
|
159
|
-
|
|
160
|
-
# Number key shortcuts (1-9)
|
|
161
|
-
for i in range(min(9, len(options))):
|
|
162
|
-
@kb.add(str(i + 1))
|
|
163
|
-
def select_by_number(event, idx=i):
|
|
164
|
-
result[0] = options[idx]
|
|
165
|
-
event.app.exit()
|
|
166
|
-
|
|
167
|
-
@kb.add("c-c")
|
|
168
|
-
@kb.add("escape")
|
|
169
|
-
def cancel(event):
|
|
170
|
-
result[0] = None
|
|
171
|
-
event.app.exit()
|
|
172
|
-
|
|
173
|
-
@kb.add("o") # 'o' for Other - custom input
|
|
174
|
-
def other_input(event):
|
|
175
|
-
result[0] = "OTHER_INPUT"
|
|
176
|
-
event.app.exit()
|
|
177
|
-
|
|
178
|
-
def get_formatted_options():
|
|
179
|
-
lines = []
|
|
180
|
-
for i, opt in enumerate(options):
|
|
181
|
-
if i == selected_index[0]:
|
|
182
|
-
lines.append(("class:selected", f" ❯ [{i+1}] {opt}\n"))
|
|
183
|
-
else:
|
|
184
|
-
lines.append(("class:option", f" [{i+1}] {opt}\n"))
|
|
185
|
-
lines.append(("class:hint", "\n↑/↓ to move, Enter to select, 1-9 for quick select, o for other"))
|
|
186
|
-
return lines
|
|
187
|
-
|
|
188
|
-
# Style
|
|
189
|
-
style = Style.from_dict({
|
|
190
|
-
"selected": "#00cc66 bold",
|
|
191
|
-
"option": "#888888",
|
|
192
|
-
"hint": "#444444 italic",
|
|
193
|
-
})
|
|
194
|
-
|
|
195
|
-
# Calculate height based on options
|
|
196
|
-
height = len(options) + 2 # options + hint line + padding
|
|
197
|
-
|
|
198
|
-
# Layout
|
|
199
|
-
layout = Layout(
|
|
200
|
-
HSplit([
|
|
201
|
-
Window(
|
|
202
|
-
FormattedTextControl(get_formatted_options),
|
|
203
|
-
height=height,
|
|
204
|
-
),
|
|
205
|
-
])
|
|
206
|
-
)
|
|
207
|
-
|
|
208
|
-
# Application
|
|
209
|
-
app = Application(
|
|
210
|
-
layout=layout,
|
|
211
|
-
key_bindings=kb,
|
|
212
|
-
style=style,
|
|
213
|
-
full_screen=False,
|
|
214
|
-
)
|
|
215
|
-
|
|
216
|
-
console.print()
|
|
217
|
-
|
|
218
|
-
try:
|
|
219
|
-
app.run()
|
|
220
|
-
except (KeyboardInterrupt, EOFError):
|
|
221
|
-
return None
|
|
222
|
-
|
|
223
|
-
# Handle "other" option - get custom input
|
|
224
|
-
if result[0] == "OTHER_INPUT":
|
|
225
|
-
session = PromptSession()
|
|
226
|
-
console.print()
|
|
227
|
-
try:
|
|
228
|
-
return session.prompt("response > ").strip() or None
|
|
229
|
-
except (KeyboardInterrupt, EOFError):
|
|
230
|
-
return None
|
|
231
|
-
|
|
232
|
-
# Check if selected option is an "other/explain" type that needs text input
|
|
233
|
-
if result[0]:
|
|
234
|
-
lower_result = result[0].lower()
|
|
235
|
-
needs_input = any(phrase in lower_result for phrase in [
|
|
236
|
-
"something else",
|
|
237
|
-
"other",
|
|
238
|
-
"i'll explain",
|
|
239
|
-
"i will explain",
|
|
240
|
-
"let me explain",
|
|
241
|
-
"custom",
|
|
242
|
-
"none of the above",
|
|
243
|
-
])
|
|
244
|
-
if needs_input:
|
|
245
|
-
session = PromptSession()
|
|
246
|
-
console.print()
|
|
247
|
-
console.print("[dim]Please explain:[/dim]")
|
|
248
|
-
try:
|
|
249
|
-
custom_input = session.prompt("response > ").strip()
|
|
250
|
-
if custom_input:
|
|
251
|
-
return custom_input
|
|
252
|
-
except (KeyboardInterrupt, EOFError):
|
|
253
|
-
return None
|
|
254
|
-
|
|
255
|
-
return result[0]
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
def _show_plan_approval_menu() -> tuple[str, str]:
|
|
259
|
-
"""Show plan approval menu with simple approve/reject options.
|
|
260
|
-
|
|
261
|
-
Returns:
|
|
262
|
-
Tuple of (choice, feedback) where feedback is only set for 'reject'
|
|
263
|
-
"""
|
|
264
|
-
from prompt_toolkit import Application
|
|
265
|
-
from prompt_toolkit.key_binding import KeyBindings
|
|
266
|
-
from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
|
|
267
|
-
from prompt_toolkit.styles import Style
|
|
268
|
-
|
|
269
|
-
options = [
|
|
270
|
-
("approve", "Approve and start implementation"),
|
|
271
|
-
("reject", "Reject and provide feedback"),
|
|
272
|
-
]
|
|
273
|
-
|
|
274
|
-
selected_index = [0] # Use list to allow mutation in closure
|
|
275
|
-
result = [None]
|
|
276
|
-
|
|
277
|
-
# Key bindings
|
|
278
|
-
kb = KeyBindings()
|
|
279
|
-
|
|
280
|
-
@kb.add("up")
|
|
281
|
-
@kb.add("k")
|
|
282
|
-
def move_up(event):
|
|
283
|
-
selected_index[0] = (selected_index[0] - 1) % len(options)
|
|
284
|
-
|
|
285
|
-
@kb.add("down")
|
|
286
|
-
@kb.add("j")
|
|
287
|
-
def move_down(event):
|
|
288
|
-
selected_index[0] = (selected_index[0] + 1) % len(options)
|
|
289
|
-
|
|
290
|
-
@kb.add("enter")
|
|
291
|
-
def select(event):
|
|
292
|
-
result[0] = options[selected_index[0]][0]
|
|
293
|
-
event.app.exit()
|
|
294
|
-
|
|
295
|
-
@kb.add("1")
|
|
296
|
-
@kb.add("y")
|
|
297
|
-
def select_approve(event):
|
|
298
|
-
result[0] = "approve"
|
|
299
|
-
event.app.exit()
|
|
300
|
-
|
|
301
|
-
@kb.add("2")
|
|
302
|
-
@kb.add("n")
|
|
303
|
-
def select_reject(event):
|
|
304
|
-
result[0] = "reject"
|
|
305
|
-
event.app.exit()
|
|
306
|
-
|
|
307
|
-
@kb.add("c-c")
|
|
308
|
-
@kb.add("q")
|
|
309
|
-
@kb.add("escape")
|
|
310
|
-
def cancel(event):
|
|
311
|
-
result[0] = "reject"
|
|
312
|
-
event.app.exit()
|
|
313
|
-
|
|
314
|
-
def get_formatted_options():
|
|
315
|
-
lines = [("class:title", "Approve this plan?\n\n")]
|
|
316
|
-
for i, (key, desc) in enumerate(options):
|
|
317
|
-
if i == selected_index[0]:
|
|
318
|
-
lines.append(("class:selected", f" ❯ {key:8} "))
|
|
319
|
-
lines.append(("class:selected-desc", f"- {desc}\n"))
|
|
320
|
-
else:
|
|
321
|
-
lines.append(("class:option", f" {key:8} "))
|
|
322
|
-
lines.append(("class:desc", f"- {desc}\n"))
|
|
323
|
-
lines.append(("class:hint", "\n↑/↓ to move, Enter to select, y/n for quick select"))
|
|
324
|
-
return lines
|
|
325
|
-
|
|
326
|
-
# Style
|
|
327
|
-
style = Style.from_dict({
|
|
328
|
-
"title": "#00ccff bold",
|
|
329
|
-
"selected": "#00cc66 bold",
|
|
330
|
-
"selected-desc": "#00cc66",
|
|
331
|
-
"option": "#888888",
|
|
332
|
-
"desc": "#666666",
|
|
333
|
-
"hint": "#444444 italic",
|
|
334
|
-
})
|
|
335
|
-
|
|
336
|
-
# Layout
|
|
337
|
-
layout = Layout(
|
|
338
|
-
HSplit([
|
|
339
|
-
Window(
|
|
340
|
-
FormattedTextControl(get_formatted_options),
|
|
341
|
-
height=6,
|
|
342
|
-
),
|
|
343
|
-
])
|
|
344
|
-
)
|
|
345
|
-
|
|
346
|
-
# Application
|
|
347
|
-
app = Application(
|
|
348
|
-
layout=layout,
|
|
349
|
-
key_bindings=kb,
|
|
350
|
-
style=style,
|
|
351
|
-
full_screen=False,
|
|
352
|
-
)
|
|
353
|
-
|
|
354
|
-
console.print()
|
|
355
|
-
|
|
356
|
-
try:
|
|
357
|
-
app.run()
|
|
358
|
-
except (KeyboardInterrupt, EOFError):
|
|
359
|
-
result[0] = "reject"
|
|
360
|
-
|
|
361
|
-
choice = result[0] or "reject"
|
|
362
|
-
|
|
363
|
-
# Get feedback if reject was chosen
|
|
364
|
-
feedback = ""
|
|
365
|
-
if choice == "reject":
|
|
366
|
-
from prompt_toolkit import PromptSession
|
|
367
|
-
console.print()
|
|
368
|
-
console.print("[dim]What changes would you like?[/dim]")
|
|
369
|
-
try:
|
|
370
|
-
session = PromptSession()
|
|
371
|
-
feedback = session.prompt("feedback > ").strip()
|
|
372
|
-
except (KeyboardInterrupt, EOFError):
|
|
373
|
-
return "reject", ""
|
|
374
|
-
|
|
375
|
-
return choice, feedback
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
def _show_plan_mode_approval_menu() -> tuple[str, str]:
|
|
379
|
-
"""Show plan mode entry approval menu.
|
|
380
|
-
|
|
381
|
-
Returns:
|
|
382
|
-
Tuple of (choice, feedback) where feedback is only set for 'reject'
|
|
383
|
-
"""
|
|
384
|
-
from prompt_toolkit import Application
|
|
385
|
-
from prompt_toolkit.key_binding import KeyBindings
|
|
386
|
-
from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
|
|
387
|
-
from prompt_toolkit.styles import Style
|
|
388
|
-
|
|
389
|
-
options = [
|
|
390
|
-
("approve", "Enter plan mode and explore"),
|
|
391
|
-
("reject", "Skip planning, proceed directly"),
|
|
392
|
-
]
|
|
393
|
-
|
|
394
|
-
selected_index = [0]
|
|
395
|
-
result = [None]
|
|
396
|
-
|
|
397
|
-
kb = KeyBindings()
|
|
398
|
-
|
|
399
|
-
@kb.add("up")
|
|
400
|
-
@kb.add("k")
|
|
401
|
-
def move_up(event):
|
|
402
|
-
selected_index[0] = (selected_index[0] - 1) % len(options)
|
|
403
|
-
|
|
404
|
-
@kb.add("down")
|
|
405
|
-
@kb.add("j")
|
|
406
|
-
def move_down(event):
|
|
407
|
-
selected_index[0] = (selected_index[0] + 1) % len(options)
|
|
408
|
-
|
|
409
|
-
@kb.add("enter")
|
|
410
|
-
def select(event):
|
|
411
|
-
result[0] = options[selected_index[0]][0]
|
|
412
|
-
event.app.exit()
|
|
413
|
-
|
|
414
|
-
@kb.add("1")
|
|
415
|
-
@kb.add("y")
|
|
416
|
-
def select_approve(event):
|
|
417
|
-
result[0] = "approve"
|
|
418
|
-
event.app.exit()
|
|
419
|
-
|
|
420
|
-
@kb.add("2")
|
|
421
|
-
@kb.add("n")
|
|
422
|
-
def select_reject(event):
|
|
423
|
-
result[0] = "reject"
|
|
424
|
-
event.app.exit()
|
|
425
|
-
|
|
426
|
-
@kb.add("c-c")
|
|
427
|
-
@kb.add("q")
|
|
428
|
-
@kb.add("escape")
|
|
429
|
-
def cancel(event):
|
|
430
|
-
result[0] = "reject"
|
|
431
|
-
event.app.exit()
|
|
432
|
-
|
|
433
|
-
def get_formatted_options():
|
|
434
|
-
lines = [("class:title", "Enter plan mode?\n\n")]
|
|
435
|
-
for i, (key, desc) in enumerate(options):
|
|
436
|
-
if i == selected_index[0]:
|
|
437
|
-
lines.append(("class:selected", f" ❯ {key:8} "))
|
|
438
|
-
lines.append(("class:selected-desc", f"- {desc}\n"))
|
|
439
|
-
else:
|
|
440
|
-
lines.append(("class:option", f" {key:8} "))
|
|
441
|
-
lines.append(("class:desc", f"- {desc}\n"))
|
|
442
|
-
lines.append(("class:hint", "\n↑/↓ to move, Enter to select, y/n for quick select"))
|
|
443
|
-
return lines
|
|
444
|
-
|
|
445
|
-
style = Style.from_dict({
|
|
446
|
-
"title": "#ffcc00 bold",
|
|
447
|
-
"selected": "#00cc66 bold",
|
|
448
|
-
"selected-desc": "#00cc66",
|
|
449
|
-
"option": "#888888",
|
|
450
|
-
"desc": "#666666",
|
|
451
|
-
"hint": "#444444 italic",
|
|
452
|
-
})
|
|
453
|
-
|
|
454
|
-
layout = Layout(
|
|
455
|
-
HSplit([
|
|
456
|
-
Window(
|
|
457
|
-
FormattedTextControl(get_formatted_options),
|
|
458
|
-
height=6,
|
|
459
|
-
),
|
|
460
|
-
])
|
|
461
|
-
)
|
|
462
|
-
|
|
463
|
-
app = Application(
|
|
464
|
-
layout=layout,
|
|
465
|
-
key_bindings=kb,
|
|
466
|
-
style=style,
|
|
467
|
-
full_screen=False,
|
|
468
|
-
)
|
|
469
|
-
|
|
470
|
-
console.print()
|
|
471
|
-
|
|
472
|
-
try:
|
|
473
|
-
app.run()
|
|
474
|
-
except (KeyboardInterrupt, EOFError):
|
|
475
|
-
result[0] = "reject"
|
|
476
|
-
|
|
477
|
-
choice = result[0] or "reject"
|
|
478
|
-
|
|
479
|
-
feedback = ""
|
|
480
|
-
if choice == "reject":
|
|
481
|
-
from prompt_toolkit import PromptSession
|
|
482
|
-
console.print()
|
|
483
|
-
console.print("[dim]Reason for skipping plan mode (optional):[/dim]")
|
|
484
|
-
try:
|
|
485
|
-
session = PromptSession()
|
|
486
|
-
feedback = session.prompt("feedback > ").strip()
|
|
487
|
-
except (KeyboardInterrupt, EOFError):
|
|
488
|
-
return "reject", ""
|
|
489
|
-
|
|
490
|
-
return choice, feedback
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
def _render_with_interrupt(renderer: SSERenderer, stream) -> dict:
|
|
494
|
-
"""Render stream with ESC key interrupt support.
|
|
495
|
-
|
|
496
|
-
Args:
|
|
497
|
-
renderer: SSE renderer instance
|
|
498
|
-
stream: SSE stream iterator
|
|
499
|
-
|
|
500
|
-
Returns:
|
|
501
|
-
Result dict from renderer, with 'interrupted' flag
|
|
502
|
-
"""
|
|
503
|
-
interrupt_event = threading.Event()
|
|
504
|
-
|
|
505
|
-
def on_escape():
|
|
506
|
-
interrupt_event.set()
|
|
507
|
-
|
|
508
|
-
listener = KeyListener(on_escape)
|
|
509
|
-
|
|
510
|
-
try:
|
|
511
|
-
listener.start()
|
|
512
|
-
result = renderer.render_stream(stream, interrupt_event=interrupt_event)
|
|
513
|
-
return result
|
|
514
|
-
finally:
|
|
515
|
-
listener.stop()
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
def _run_single_task(
|
|
519
|
-
client: EmdashClient,
|
|
520
|
-
renderer: SSERenderer,
|
|
521
|
-
task: str,
|
|
522
|
-
model: str | None,
|
|
523
|
-
max_iterations: int,
|
|
524
|
-
options: dict,
|
|
525
|
-
):
|
|
526
|
-
"""Run a single agent task."""
|
|
527
|
-
try:
|
|
528
|
-
stream = client.agent_chat_stream(
|
|
529
|
-
message=task,
|
|
530
|
-
model=model,
|
|
531
|
-
max_iterations=max_iterations,
|
|
532
|
-
options=options,
|
|
533
|
-
)
|
|
534
|
-
result = _render_with_interrupt(renderer, stream)
|
|
535
|
-
if result.get("interrupted"):
|
|
536
|
-
console.print("[dim]Task interrupted. You can continue or start a new task.[/dim]")
|
|
537
|
-
except Exception as e:
|
|
538
|
-
console.print(f"[red]Error: {e}[/red]")
|
|
539
|
-
raise click.Abort()
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
def _run_slash_command_task(
|
|
543
|
-
client: EmdashClient,
|
|
544
|
-
renderer: SSERenderer,
|
|
545
|
-
model: str | None,
|
|
546
|
-
max_iterations: int,
|
|
547
|
-
task: str,
|
|
548
|
-
options: dict,
|
|
549
|
-
):
|
|
550
|
-
"""Run a task from a slash command."""
|
|
551
|
-
try:
|
|
552
|
-
stream = client.agent_chat_stream(
|
|
553
|
-
message=task,
|
|
554
|
-
model=model,
|
|
555
|
-
max_iterations=max_iterations,
|
|
556
|
-
options=options,
|
|
557
|
-
)
|
|
558
|
-
result = _render_with_interrupt(renderer, stream)
|
|
559
|
-
if result.get("interrupted"):
|
|
560
|
-
console.print("[dim]Task interrupted.[/dim]")
|
|
561
|
-
console.print()
|
|
562
|
-
except Exception as e:
|
|
563
|
-
console.print(f"[red]Error: {e}[/red]")
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
def _run_interactive(
|
|
567
|
-
client: EmdashClient,
|
|
568
|
-
renderer: SSERenderer,
|
|
569
|
-
model: str | None,
|
|
570
|
-
max_iterations: int,
|
|
571
|
-
options: dict,
|
|
572
|
-
):
|
|
573
|
-
"""Run interactive REPL mode with slash commands."""
|
|
574
|
-
from prompt_toolkit import PromptSession
|
|
575
|
-
from prompt_toolkit.history import FileHistory
|
|
576
|
-
from prompt_toolkit.completion import Completer, Completion
|
|
577
|
-
from prompt_toolkit.styles import Style
|
|
578
|
-
from prompt_toolkit.key_binding import KeyBindings
|
|
579
|
-
from pathlib import Path
|
|
580
|
-
|
|
581
|
-
# Current mode
|
|
582
|
-
current_mode = AgentMode(options.get("mode", "code"))
|
|
583
|
-
session_id = None
|
|
584
|
-
current_spec = None
|
|
585
|
-
# Attached images for next message
|
|
586
|
-
attached_images: list[dict] = []
|
|
587
|
-
# Loaded messages from saved session (for restoration)
|
|
588
|
-
loaded_messages: list[dict] = []
|
|
589
|
-
|
|
590
|
-
# Style for prompt
|
|
591
|
-
PROMPT_STYLE = Style.from_dict({
|
|
592
|
-
"prompt.mode.plan": "#ffcc00 bold",
|
|
593
|
-
"prompt.mode.code": "#00cc66 bold",
|
|
594
|
-
"prompt.prefix": "#888888",
|
|
595
|
-
"prompt.image": "#00ccff",
|
|
596
|
-
"completion-menu": "bg:#1a1a2e #ffffff",
|
|
597
|
-
"completion-menu.completion": "bg:#1a1a2e #ffffff",
|
|
598
|
-
"completion-menu.completion.current": "bg:#4a4a6e #ffffff bold",
|
|
599
|
-
"completion-menu.meta.completion": "bg:#1a1a2e #888888",
|
|
600
|
-
"completion-menu.meta.completion.current": "bg:#4a4a6e #aaaaaa",
|
|
601
|
-
"command": "#00ccff bold",
|
|
602
|
-
})
|
|
603
|
-
|
|
604
|
-
class SlashCommandCompleter(Completer):
|
|
605
|
-
"""Completer for slash commands."""
|
|
606
|
-
|
|
607
|
-
def get_completions(self, document, complete_event):
|
|
608
|
-
text = document.text_before_cursor
|
|
609
|
-
if not text.startswith("/"):
|
|
610
|
-
return
|
|
611
|
-
for cmd, description in SLASH_COMMANDS.items():
|
|
612
|
-
# Extract base command (e.g., "/pr" from "/pr [url]")
|
|
613
|
-
base_cmd = cmd.split()[0]
|
|
614
|
-
if base_cmd.startswith(text):
|
|
615
|
-
yield Completion(
|
|
616
|
-
base_cmd,
|
|
617
|
-
start_position=-len(text),
|
|
618
|
-
display=cmd,
|
|
619
|
-
display_meta=description,
|
|
620
|
-
)
|
|
621
|
-
|
|
622
|
-
# Setup history file
|
|
623
|
-
history_file = Path.home() / ".emdash" / "cli_history"
|
|
624
|
-
history_file.parent.mkdir(parents=True, exist_ok=True)
|
|
625
|
-
history = FileHistory(str(history_file))
|
|
626
|
-
|
|
627
|
-
# Key bindings: Enter submits, Alt+Enter inserts newline
|
|
628
|
-
# Note: Shift+Enter is indistinguishable from Enter in most terminals
|
|
629
|
-
kb = KeyBindings()
|
|
630
|
-
|
|
631
|
-
@kb.add("enter")
|
|
632
|
-
def submit_on_enter(event):
|
|
633
|
-
"""Submit on Enter."""
|
|
634
|
-
event.current_buffer.validate_and_handle()
|
|
635
|
-
|
|
636
|
-
@kb.add("escape", "enter") # Alt+Enter (Escape then Enter)
|
|
637
|
-
@kb.add("c-j") # Ctrl+J as alternative for newline
|
|
638
|
-
def insert_newline_alt(event):
|
|
639
|
-
"""Insert a newline character with Alt+Enter or Ctrl+J."""
|
|
640
|
-
event.current_buffer.insert_text("\n")
|
|
641
|
-
|
|
642
|
-
@kb.add("c-v") # Ctrl+V to paste (check for images)
|
|
643
|
-
def paste_with_image_check(event):
|
|
644
|
-
"""Paste text or attach image from clipboard."""
|
|
645
|
-
nonlocal attached_images
|
|
646
|
-
from ..clipboard import get_clipboard_image, get_image_from_path
|
|
647
|
-
|
|
648
|
-
# Try to get image from clipboard
|
|
649
|
-
image_data = get_clipboard_image()
|
|
650
|
-
if image_data:
|
|
651
|
-
base64_data, img_format = image_data
|
|
652
|
-
attached_images.append({"data": base64_data, "format": img_format})
|
|
653
|
-
# Refresh prompt to show updated image list
|
|
654
|
-
event.app.invalidate()
|
|
655
|
-
return
|
|
656
|
-
|
|
657
|
-
# Check if clipboard contains an image file path
|
|
658
|
-
clipboard_data = event.app.clipboard.get_data()
|
|
659
|
-
if clipboard_data and clipboard_data.text:
|
|
660
|
-
text = clipboard_data.text.strip()
|
|
661
|
-
# Remove escape characters from dragged paths (e.g., "path\ with\ spaces")
|
|
662
|
-
clean_path = text.replace("\\ ", " ")
|
|
663
|
-
# Check if it looks like an image file path
|
|
664
|
-
if clean_path.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')):
|
|
665
|
-
image_data = get_image_from_path(clean_path)
|
|
666
|
-
if image_data:
|
|
667
|
-
base64_data, img_format = image_data
|
|
668
|
-
attached_images.append({"data": base64_data, "format": img_format})
|
|
669
|
-
event.app.invalidate()
|
|
670
|
-
return
|
|
671
|
-
|
|
672
|
-
# No image, do normal paste
|
|
673
|
-
event.current_buffer.paste_clipboard_data(clipboard_data)
|
|
674
|
-
|
|
675
|
-
def check_for_image_path(buff):
|
|
676
|
-
"""Check if buffer contains an image path and attach it."""
|
|
677
|
-
nonlocal attached_images
|
|
678
|
-
text = buff.text.strip()
|
|
679
|
-
if not text:
|
|
680
|
-
return
|
|
681
|
-
# Clean escaped spaces from dragged paths
|
|
682
|
-
clean_text = text.replace("\\ ", " ")
|
|
683
|
-
if clean_text.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')):
|
|
684
|
-
from ..clipboard import get_image_from_path
|
|
685
|
-
from prompt_toolkit.application import get_app
|
|
686
|
-
image_data = get_image_from_path(clean_text)
|
|
687
|
-
if image_data:
|
|
688
|
-
base64_data, img_format = image_data
|
|
689
|
-
attached_images.append({"data": base64_data, "format": img_format})
|
|
690
|
-
# Clear the buffer
|
|
691
|
-
buff.text = ""
|
|
692
|
-
buff.cursor_position = 0
|
|
693
|
-
# Refresh prompt to show image indicator
|
|
694
|
-
try:
|
|
695
|
-
get_app().invalidate()
|
|
696
|
-
except Exception:
|
|
697
|
-
pass
|
|
698
|
-
|
|
699
|
-
session = PromptSession(
|
|
700
|
-
history=history,
|
|
701
|
-
completer=SlashCommandCompleter(),
|
|
702
|
-
style=PROMPT_STYLE,
|
|
703
|
-
complete_while_typing=True,
|
|
704
|
-
multiline=True,
|
|
705
|
-
prompt_continuation="... ",
|
|
706
|
-
key_bindings=kb,
|
|
707
|
-
)
|
|
708
|
-
|
|
709
|
-
# Watch for image paths being pasted/dropped
|
|
710
|
-
session.default_buffer.on_text_changed += check_for_image_path
|
|
711
|
-
|
|
712
|
-
def get_prompt():
|
|
713
|
-
"""Get formatted prompt."""
|
|
714
|
-
nonlocal attached_images
|
|
715
|
-
parts = []
|
|
716
|
-
# Show attached images above prompt
|
|
717
|
-
if attached_images:
|
|
718
|
-
image_tags = " ".join(f"[Image #{i+1}]" for i in range(len(attached_images)))
|
|
719
|
-
parts.append(("class:prompt.image", f" {image_tags}\n"))
|
|
720
|
-
parts.append(("class:prompt.prefix", "> "))
|
|
721
|
-
return parts
|
|
722
|
-
|
|
723
|
-
def show_help():
|
|
724
|
-
"""Show available commands."""
|
|
725
|
-
console.print()
|
|
726
|
-
console.print("[bold cyan]Available Commands[/bold cyan]")
|
|
727
|
-
console.print()
|
|
728
|
-
for cmd, desc in SLASH_COMMANDS.items():
|
|
729
|
-
console.print(f" [cyan]{cmd:12}[/cyan] {desc}")
|
|
730
|
-
console.print()
|
|
731
|
-
console.print("[dim]Type your task or question to interact with the agent.[/dim]")
|
|
732
|
-
console.print()
|
|
733
|
-
|
|
734
|
-
def handle_slash_command(cmd: str) -> bool:
|
|
735
|
-
"""Handle a slash command. Returns True if should continue, False to exit."""
|
|
736
|
-
nonlocal current_mode, session_id, current_spec
|
|
737
|
-
|
|
738
|
-
cmd_parts = cmd.strip().split(maxsplit=1)
|
|
739
|
-
command = cmd_parts[0].lower()
|
|
740
|
-
args = cmd_parts[1] if len(cmd_parts) > 1 else ""
|
|
741
|
-
|
|
742
|
-
if command == "/quit" or command == "/exit" or command == "/q":
|
|
743
|
-
return False
|
|
744
|
-
|
|
745
|
-
elif command == "/help":
|
|
746
|
-
show_help()
|
|
747
|
-
|
|
748
|
-
elif command == "/plan":
|
|
749
|
-
current_mode = AgentMode.PLAN
|
|
750
|
-
# Reset session so next chat creates a new session with plan mode
|
|
751
|
-
if session_id:
|
|
752
|
-
session_id = None
|
|
753
|
-
console.print("[bold green]✓ Plan mode activated[/bold green] [dim](session reset)[/dim]")
|
|
754
|
-
else:
|
|
755
|
-
console.print("[bold green]✓ Plan mode activated[/bold green]")
|
|
756
|
-
|
|
757
|
-
elif command == "/code":
|
|
758
|
-
current_mode = AgentMode.CODE
|
|
759
|
-
# Reset session so next chat creates a new session with code mode
|
|
760
|
-
if session_id:
|
|
761
|
-
session_id = None
|
|
762
|
-
console.print("[green]Switched to code mode (session reset)[/green]")
|
|
763
|
-
else:
|
|
764
|
-
console.print("[green]Switched to code mode[/green]")
|
|
765
|
-
|
|
766
|
-
elif command == "/mode":
|
|
767
|
-
console.print(f"Current mode: [bold]{current_mode.value}[/bold]")
|
|
768
|
-
|
|
769
|
-
elif command == "/reset":
|
|
770
|
-
session_id = None
|
|
771
|
-
current_spec = None
|
|
772
|
-
console.print("[dim]Session reset[/dim]")
|
|
773
|
-
|
|
774
|
-
elif command == "/spec":
|
|
775
|
-
if current_spec:
|
|
776
|
-
console.print(Panel(Markdown(current_spec), title="Current Spec"))
|
|
777
|
-
else:
|
|
778
|
-
console.print("[dim]No spec available. Use plan mode to create one.[/dim]")
|
|
779
|
-
|
|
780
|
-
elif command == "/save":
|
|
781
|
-
if current_spec:
|
|
782
|
-
# TODO: Save spec via API
|
|
783
|
-
console.print("[yellow]Save not implemented yet[/yellow]")
|
|
784
|
-
else:
|
|
785
|
-
console.print("[dim]No spec to save[/dim]")
|
|
786
|
-
|
|
787
|
-
elif command == "/pr":
|
|
788
|
-
# PR review
|
|
789
|
-
if not args:
|
|
790
|
-
console.print("[yellow]Usage: /pr <pr-url-or-number>[/yellow]")
|
|
791
|
-
console.print("[dim]Example: /pr 123 or /pr https://github.com/org/repo/pull/123[/dim]")
|
|
792
|
-
else:
|
|
793
|
-
console.print(f"[cyan]Reviewing PR: {args}[/cyan]")
|
|
794
|
-
_run_slash_command_task(
|
|
795
|
-
client, renderer, model, max_iterations,
|
|
796
|
-
f"Review this pull request and provide feedback: {args}",
|
|
797
|
-
{"mode": "code"}
|
|
798
|
-
)
|
|
799
|
-
|
|
800
|
-
elif command == "/projectmd":
|
|
801
|
-
# Generate PROJECT.md
|
|
802
|
-
console.print("[cyan]Generating PROJECT.md...[/cyan]")
|
|
803
|
-
_run_slash_command_task(
|
|
804
|
-
client, renderer, model, max_iterations,
|
|
805
|
-
"Analyze this codebase and generate a comprehensive PROJECT.md file that describes the architecture, main components, how to get started, and key design decisions.",
|
|
806
|
-
{"mode": "code"}
|
|
807
|
-
)
|
|
808
|
-
|
|
809
|
-
elif command == "/research":
|
|
810
|
-
# Deep research
|
|
811
|
-
if not args:
|
|
812
|
-
console.print("[yellow]Usage: /research <goal>[/yellow]")
|
|
813
|
-
console.print("[dim]Example: /research How does authentication work in this codebase?[/dim]")
|
|
814
|
-
else:
|
|
815
|
-
console.print(f"[cyan]Researching: {args}[/cyan]")
|
|
816
|
-
_run_slash_command_task(
|
|
817
|
-
client, renderer, model, 50, # More iterations for research
|
|
818
|
-
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.",
|
|
819
|
-
{"mode": "plan"} # Use plan mode for research
|
|
820
|
-
)
|
|
821
|
-
|
|
822
|
-
elif command == "/status":
|
|
823
|
-
# Show index and PROJECT.md status
|
|
824
|
-
from datetime import datetime
|
|
825
|
-
|
|
826
|
-
console.print("\n[bold cyan]Status[/bold cyan]\n")
|
|
827
|
-
|
|
828
|
-
# Index status
|
|
829
|
-
console.print("[bold]Index Status[/bold]")
|
|
830
|
-
try:
|
|
831
|
-
status = client.index_status(str(Path.cwd()))
|
|
832
|
-
is_indexed = status.get("is_indexed", False)
|
|
833
|
-
console.print(f" Indexed: {'[green]Yes[/green]' if is_indexed else '[yellow]No[/yellow]'}")
|
|
834
|
-
|
|
835
|
-
if is_indexed:
|
|
836
|
-
console.print(f" Files: {status.get('file_count', 0)}")
|
|
837
|
-
console.print(f" Functions: {status.get('function_count', 0)}")
|
|
838
|
-
console.print(f" Classes: {status.get('class_count', 0)}")
|
|
839
|
-
console.print(f" Communities: {status.get('community_count', 0)}")
|
|
840
|
-
if status.get("last_indexed"):
|
|
841
|
-
console.print(f" Last indexed: {status.get('last_indexed')}")
|
|
842
|
-
if status.get("last_commit"):
|
|
843
|
-
console.print(f" Last commit: {status.get('last_commit')}")
|
|
844
|
-
except Exception as e:
|
|
845
|
-
console.print(f" [red]Error fetching index status: {e}[/red]")
|
|
846
|
-
|
|
847
|
-
console.print()
|
|
848
|
-
|
|
849
|
-
# PROJECT.md status
|
|
850
|
-
console.print("[bold]PROJECT.md Status[/bold]")
|
|
851
|
-
projectmd_path = Path.cwd() / "PROJECT.md"
|
|
852
|
-
if projectmd_path.exists():
|
|
853
|
-
stat = projectmd_path.stat()
|
|
854
|
-
modified_time = datetime.fromtimestamp(stat.st_mtime)
|
|
855
|
-
size_kb = stat.st_size / 1024
|
|
856
|
-
console.print(f" Exists: [green]Yes[/green]")
|
|
857
|
-
console.print(f" Path: {projectmd_path}")
|
|
858
|
-
console.print(f" Size: {size_kb:.1f} KB")
|
|
859
|
-
console.print(f" Last modified: {modified_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
860
|
-
else:
|
|
861
|
-
console.print(f" Exists: [yellow]No[/yellow]")
|
|
862
|
-
console.print("[dim] Run /projectmd to generate it[/dim]")
|
|
863
|
-
|
|
864
|
-
console.print()
|
|
865
|
-
|
|
866
|
-
elif command == "/agents":
|
|
867
|
-
# Agents management: list, create, show
|
|
868
|
-
from emdash_core.agent.toolkits import list_agent_types, get_custom_agent
|
|
869
|
-
|
|
870
|
-
# Parse subcommand
|
|
871
|
-
subparts = args.split(maxsplit=1) if args else []
|
|
872
|
-
subcommand = subparts[0].lower() if subparts else "list"
|
|
873
|
-
subargs = subparts[1] if len(subparts) > 1 else ""
|
|
874
|
-
|
|
875
|
-
if subcommand in ("list", ""):
|
|
876
|
-
# List all agents
|
|
877
|
-
console.print("\n[bold cyan]Available Agents[/bold cyan]\n")
|
|
878
|
-
|
|
879
|
-
all_agents = list_agent_types(Path.cwd())
|
|
880
|
-
builtin = ["Explore", "Plan"]
|
|
881
|
-
|
|
882
|
-
console.print("[bold]Built-in Agents[/bold]")
|
|
883
|
-
for agent_name in builtin:
|
|
884
|
-
if agent_name == "Explore":
|
|
885
|
-
console.print(" [green]Explore[/green] - Fast codebase exploration (read-only)")
|
|
886
|
-
elif agent_name == "Plan":
|
|
887
|
-
console.print(" [green]Plan[/green] - Design implementation plans")
|
|
888
|
-
|
|
889
|
-
# Custom agents
|
|
890
|
-
custom = [a for a in all_agents if a not in builtin]
|
|
891
|
-
if custom:
|
|
892
|
-
console.print("\n[bold]Custom Agents[/bold] [dim](.emdash/agents/)[/dim]")
|
|
893
|
-
for name in custom:
|
|
894
|
-
agent = get_custom_agent(name, Path.cwd())
|
|
895
|
-
desc = agent.description if agent else ""
|
|
896
|
-
if desc:
|
|
897
|
-
console.print(f" [cyan]{name}[/cyan] - {desc}")
|
|
898
|
-
else:
|
|
899
|
-
console.print(f" [cyan]{name}[/cyan]")
|
|
900
|
-
else:
|
|
901
|
-
console.print("\n[dim]No custom agents found.[/dim]")
|
|
902
|
-
console.print("[dim]Create with: /agents create <name>[/dim]")
|
|
903
|
-
|
|
904
|
-
console.print()
|
|
905
|
-
|
|
906
|
-
elif subcommand == "create":
|
|
907
|
-
# Create a new custom agent
|
|
908
|
-
if not subargs:
|
|
909
|
-
console.print("[yellow]Usage: /agents create <name>[/yellow]")
|
|
910
|
-
console.print("[dim]Example: /agents create code-reviewer[/dim]")
|
|
911
|
-
else:
|
|
912
|
-
agent_name = subargs.strip().lower().replace(" ", "-")
|
|
913
|
-
agents_dir = Path.cwd() / ".emdash" / "agents"
|
|
914
|
-
agent_file = agents_dir / f"{agent_name}.md"
|
|
915
|
-
|
|
916
|
-
if agent_file.exists():
|
|
917
|
-
console.print(f"[yellow]Agent '{agent_name}' already exists[/yellow]")
|
|
918
|
-
console.print(f"[dim]Edit: {agent_file}[/dim]")
|
|
919
|
-
else:
|
|
920
|
-
# Create directory if needed
|
|
921
|
-
agents_dir.mkdir(parents=True, exist_ok=True)
|
|
922
|
-
|
|
923
|
-
# Create template
|
|
924
|
-
template = f'''---
|
|
925
|
-
description: Custom agent for specific tasks
|
|
926
|
-
tools: [grep, glob, read_file, semantic_search]
|
|
927
|
-
---
|
|
928
|
-
|
|
929
|
-
# System Prompt
|
|
930
|
-
|
|
931
|
-
You are a specialized assistant for {agent_name.replace("-", " ")} tasks.
|
|
932
|
-
|
|
933
|
-
## Your Mission
|
|
934
|
-
|
|
935
|
-
Describe what this agent should accomplish:
|
|
936
|
-
- Task 1
|
|
937
|
-
- Task 2
|
|
938
|
-
- Task 3
|
|
939
|
-
|
|
940
|
-
## Approach
|
|
941
|
-
|
|
942
|
-
1. **Step One**
|
|
943
|
-
- Details about the first step
|
|
944
|
-
|
|
945
|
-
2. **Step Two**
|
|
946
|
-
- Details about the second step
|
|
947
|
-
|
|
948
|
-
## Output Format
|
|
949
|
-
|
|
950
|
-
Describe how the agent should format its responses.
|
|
951
|
-
|
|
952
|
-
# Examples
|
|
953
|
-
|
|
954
|
-
## Example 1
|
|
955
|
-
User: Example user request
|
|
956
|
-
Agent: Example agent response describing what it would do
|
|
957
|
-
'''
|
|
958
|
-
agent_file.write_text(template)
|
|
959
|
-
console.print(f"[green]Created agent: {agent_name}[/green]")
|
|
960
|
-
console.print(f"[dim]Edit to customize: {agent_file}[/dim]")
|
|
961
|
-
console.print(f"\n[dim]Spawn with Task tool: subagent_type='{agent_name}'[/dim]")
|
|
962
|
-
|
|
963
|
-
elif subcommand == "show":
|
|
964
|
-
# Show details of an agent
|
|
965
|
-
if not subargs:
|
|
966
|
-
console.print("[yellow]Usage: /agents show <name>[/yellow]")
|
|
967
|
-
else:
|
|
968
|
-
agent_name = subargs.strip()
|
|
969
|
-
builtin = ["Explore", "Plan"]
|
|
970
|
-
|
|
971
|
-
if agent_name in builtin:
|
|
972
|
-
console.print(f"\n[bold cyan]{agent_name}[/bold cyan] [dim](built-in)[/dim]\n")
|
|
973
|
-
if agent_name == "Explore":
|
|
974
|
-
console.print("Fast codebase exploration agent (read-only)")
|
|
975
|
-
console.print("\n[bold]Tools:[/bold] glob, grep, read_file, list_files, semantic_search")
|
|
976
|
-
elif agent_name == "Plan":
|
|
977
|
-
console.print("Implementation planning agent")
|
|
978
|
-
console.print("\n[bold]Tools:[/bold] glob, grep, read_file, list_files, semantic_search")
|
|
979
|
-
console.print()
|
|
980
|
-
else:
|
|
981
|
-
agent = get_custom_agent(agent_name, Path.cwd())
|
|
982
|
-
if agent:
|
|
983
|
-
console.print(f"\n[bold cyan]{agent.name}[/bold cyan] [dim](custom)[/dim]\n")
|
|
984
|
-
if agent.description:
|
|
985
|
-
console.print(f"[bold]Description:[/bold] {agent.description}")
|
|
986
|
-
if agent.tools:
|
|
987
|
-
console.print(f"[bold]Tools:[/bold] {', '.join(agent.tools)}")
|
|
988
|
-
if agent.file_path:
|
|
989
|
-
console.print(f"[bold]File:[/bold] {agent.file_path}")
|
|
990
|
-
if agent.system_prompt:
|
|
991
|
-
console.print(f"\n[bold]System Prompt:[/bold]")
|
|
992
|
-
# Show first 500 chars of system prompt
|
|
993
|
-
preview = agent.system_prompt[:500]
|
|
994
|
-
if len(agent.system_prompt) > 500:
|
|
995
|
-
preview += "..."
|
|
996
|
-
console.print(Panel(preview, border_style="dim"))
|
|
997
|
-
console.print()
|
|
998
|
-
else:
|
|
999
|
-
console.print(f"[yellow]Agent '{agent_name}' not found[/yellow]")
|
|
1000
|
-
console.print("[dim]Use /agents to list available agents[/dim]")
|
|
1001
|
-
|
|
1002
|
-
else:
|
|
1003
|
-
console.print(f"[yellow]Unknown subcommand: {subcommand}[/yellow]")
|
|
1004
|
-
console.print("[dim]Usage: /agents [list|create|show] [name][/dim]")
|
|
1005
|
-
|
|
1006
|
-
elif command == "/session":
|
|
1007
|
-
# Session management: list, save, load, delete, clear
|
|
1008
|
-
from ..session_store import SessionStore
|
|
1009
|
-
|
|
1010
|
-
store = SessionStore(Path.cwd())
|
|
1011
|
-
|
|
1012
|
-
# Parse subcommand
|
|
1013
|
-
subparts = args.split(maxsplit=1) if args else []
|
|
1014
|
-
subcommand = subparts[0].lower() if subparts else "list"
|
|
1015
|
-
subargs = subparts[1].strip() if len(subparts) > 1 else ""
|
|
1016
|
-
|
|
1017
|
-
if subcommand == "list" or subcommand == "":
|
|
1018
|
-
# List all sessions
|
|
1019
|
-
sessions = store.list_sessions()
|
|
1020
|
-
if sessions:
|
|
1021
|
-
console.print("\n[bold cyan]Saved Sessions[/bold cyan]\n")
|
|
1022
|
-
for s in sessions:
|
|
1023
|
-
mode_color = "green" if s.mode == "code" else "yellow"
|
|
1024
|
-
active_marker = " [bold green]*[/bold green]" if store.get_active_session() == s.name else ""
|
|
1025
|
-
console.print(f" [cyan]{s.name}[/cyan]{active_marker} [{mode_color}]{s.mode}[/{mode_color}]")
|
|
1026
|
-
console.print(f" [dim]{s.message_count} messages | {s.updated_at[:10]}[/dim]")
|
|
1027
|
-
if s.summary:
|
|
1028
|
-
summary = s.summary[:60] + "..." if len(s.summary) > 60 else s.summary
|
|
1029
|
-
console.print(f" [dim]{summary}[/dim]")
|
|
1030
|
-
console.print()
|
|
1031
|
-
else:
|
|
1032
|
-
console.print("\n[dim]No saved sessions.[/dim]")
|
|
1033
|
-
console.print("[dim]Save with: /session save <name>[/dim]\n")
|
|
1034
|
-
|
|
1035
|
-
elif subcommand == "save":
|
|
1036
|
-
if not subargs:
|
|
1037
|
-
console.print("[yellow]Usage: /session save <name>[/yellow]")
|
|
1038
|
-
console.print("[dim]Example: /session save auth-feature[/dim]")
|
|
1039
|
-
else:
|
|
1040
|
-
# Get current messages from the API session
|
|
1041
|
-
if session_id:
|
|
1042
|
-
try:
|
|
1043
|
-
# Export messages from server
|
|
1044
|
-
export_resp = client.get(f"/api/agent/chat/{session_id}/export")
|
|
1045
|
-
if export_resp.status_code == 200:
|
|
1046
|
-
data = export_resp.json()
|
|
1047
|
-
messages = data.get("messages", [])
|
|
1048
|
-
else:
|
|
1049
|
-
messages = []
|
|
1050
|
-
except Exception:
|
|
1051
|
-
messages = []
|
|
1052
|
-
else:
|
|
1053
|
-
messages = []
|
|
1054
|
-
|
|
1055
|
-
success, msg = store.save_session(
|
|
1056
|
-
name=subargs,
|
|
1057
|
-
messages=messages,
|
|
1058
|
-
mode=current_mode.value,
|
|
1059
|
-
spec=current_spec,
|
|
1060
|
-
model=model,
|
|
1061
|
-
)
|
|
1062
|
-
if success:
|
|
1063
|
-
store.set_active_session(subargs)
|
|
1064
|
-
console.print(f"[green]{msg}[/green]")
|
|
1065
|
-
else:
|
|
1066
|
-
console.print(f"[yellow]{msg}[/yellow]")
|
|
1067
|
-
|
|
1068
|
-
elif subcommand == "load":
|
|
1069
|
-
if not subargs:
|
|
1070
|
-
console.print("[yellow]Usage: /session load <name>[/yellow]")
|
|
1071
|
-
else:
|
|
1072
|
-
session_data = store.load_session(subargs)
|
|
1073
|
-
if session_data:
|
|
1074
|
-
# Reset current session
|
|
1075
|
-
session_id = None
|
|
1076
|
-
current_spec = session_data.spec
|
|
1077
|
-
if session_data.mode == "plan":
|
|
1078
|
-
current_mode = AgentMode.PLAN
|
|
1079
|
-
else:
|
|
1080
|
-
current_mode = AgentMode.CODE
|
|
1081
|
-
|
|
1082
|
-
# Store loaded messages for replay
|
|
1083
|
-
nonlocal loaded_messages
|
|
1084
|
-
loaded_messages = session_data.messages
|
|
1085
|
-
|
|
1086
|
-
store.set_active_session(subargs)
|
|
1087
|
-
console.print(f"[green]Loaded session '{subargs}'[/green]")
|
|
1088
|
-
console.print(f"[dim]{len(session_data.messages)} messages restored, mode: {current_mode.value}[/dim]")
|
|
1089
|
-
if current_spec:
|
|
1090
|
-
console.print("[dim]Spec restored[/dim]")
|
|
1091
|
-
else:
|
|
1092
|
-
console.print(f"[yellow]Session '{subargs}' not found[/yellow]")
|
|
1093
|
-
|
|
1094
|
-
elif subcommand == "delete":
|
|
1095
|
-
if not subargs:
|
|
1096
|
-
console.print("[yellow]Usage: /session delete <name>[/yellow]")
|
|
1097
|
-
else:
|
|
1098
|
-
success, msg = store.delete_session(subargs)
|
|
1099
|
-
if success:
|
|
1100
|
-
console.print(f"[green]{msg}[/green]")
|
|
1101
|
-
else:
|
|
1102
|
-
console.print(f"[yellow]{msg}[/yellow]")
|
|
1103
|
-
|
|
1104
|
-
elif subcommand == "clear":
|
|
1105
|
-
# Clear current session state
|
|
1106
|
-
session_id = None
|
|
1107
|
-
current_spec = None
|
|
1108
|
-
loaded_messages = []
|
|
1109
|
-
store.set_active_session(None)
|
|
1110
|
-
console.print("[green]Session cleared[/green]")
|
|
1111
|
-
|
|
1112
|
-
else:
|
|
1113
|
-
console.print(f"[yellow]Unknown subcommand: {subcommand}[/yellow]")
|
|
1114
|
-
console.print("[dim]Usage: /session [list|save|load|delete|clear] [name][/dim]")
|
|
1115
|
-
|
|
1116
|
-
else:
|
|
1117
|
-
console.print(f"[yellow]Unknown command: {command}[/yellow]")
|
|
1118
|
-
console.print("[dim]Type /help for available commands[/dim]")
|
|
1119
|
-
|
|
1120
|
-
return True
|
|
1121
|
-
|
|
1122
|
-
# Show welcome message
|
|
1123
|
-
from .. import __version__
|
|
1124
|
-
import subprocess
|
|
1125
|
-
|
|
1126
|
-
# Get current working directory
|
|
1127
|
-
cwd = Path.cwd()
|
|
1128
|
-
|
|
1129
|
-
# Get git repo name (if in a git repo)
|
|
1130
|
-
git_repo = None
|
|
1131
|
-
try:
|
|
1132
|
-
result = subprocess.run(
|
|
1133
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
1134
|
-
capture_output=True, text=True, cwd=cwd
|
|
1135
|
-
)
|
|
1136
|
-
if result.returncode == 0:
|
|
1137
|
-
git_repo = Path(result.stdout.strip()).name
|
|
1138
|
-
except Exception:
|
|
1139
|
-
pass
|
|
1140
|
-
|
|
1141
|
-
# Welcome banner
|
|
1142
|
-
console.print()
|
|
1143
|
-
console.print(f"[bold cyan]Mendy10 Emdash Code[/bold cyan] [dim]v{__version__}[/dim]")
|
|
1144
|
-
if git_repo:
|
|
1145
|
-
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'}")
|
|
1146
|
-
else:
|
|
1147
|
-
console.print(f"[dim]Mode:[/dim] [bold]{current_mode.value}[/bold] [dim]| Model:[/dim] {model or 'default'}")
|
|
1148
|
-
console.print()
|
|
1149
|
-
|
|
1150
|
-
while True:
|
|
1151
|
-
try:
|
|
1152
|
-
# Get user input
|
|
1153
|
-
user_input = session.prompt(get_prompt()).strip()
|
|
1154
|
-
|
|
1155
|
-
if not user_input:
|
|
1156
|
-
continue
|
|
1157
|
-
|
|
1158
|
-
# Check if input is an image file path (dragged file)
|
|
1159
|
-
clean_input = user_input.replace("\\ ", " ")
|
|
1160
|
-
if clean_input.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp')):
|
|
1161
|
-
from ..clipboard import get_image_from_path
|
|
1162
|
-
image_data = get_image_from_path(clean_input)
|
|
1163
|
-
if image_data:
|
|
1164
|
-
base64_data, img_format = image_data
|
|
1165
|
-
attached_images.append({"data": base64_data, "format": img_format})
|
|
1166
|
-
continue # Prompt again for actual message
|
|
1167
|
-
|
|
1168
|
-
# Handle slash commands
|
|
1169
|
-
if user_input.startswith("/"):
|
|
1170
|
-
if not handle_slash_command(user_input):
|
|
1171
|
-
break
|
|
1172
|
-
continue
|
|
1173
|
-
|
|
1174
|
-
# Handle quit shortcuts
|
|
1175
|
-
if user_input.lower() in ("quit", "exit", "q"):
|
|
1176
|
-
break
|
|
1177
|
-
|
|
1178
|
-
# Build options with current mode
|
|
1179
|
-
request_options = {
|
|
1180
|
-
**options,
|
|
1181
|
-
"mode": current_mode.value,
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
# Run agent with current mode
|
|
1185
|
-
try:
|
|
1186
|
-
# Prepare images for API call
|
|
1187
|
-
images_to_send = attached_images if attached_images else None
|
|
1188
|
-
|
|
1189
|
-
if session_id:
|
|
1190
|
-
stream = client.agent_continue_stream(
|
|
1191
|
-
session_id, user_input, images=images_to_send
|
|
1192
|
-
)
|
|
1193
|
-
else:
|
|
1194
|
-
# Pass loaded_messages from saved session if available
|
|
1195
|
-
stream = client.agent_chat_stream(
|
|
1196
|
-
message=user_input,
|
|
1197
|
-
model=model,
|
|
1198
|
-
max_iterations=max_iterations,
|
|
1199
|
-
options=request_options,
|
|
1200
|
-
images=images_to_send,
|
|
1201
|
-
history=loaded_messages if loaded_messages else None,
|
|
1202
|
-
)
|
|
1203
|
-
# Clear loaded_messages after first use
|
|
1204
|
-
loaded_messages = []
|
|
1205
|
-
|
|
1206
|
-
# Clear attached images after sending
|
|
1207
|
-
attached_images = []
|
|
1208
|
-
|
|
1209
|
-
# Render the stream and capture any spec output
|
|
1210
|
-
result = _render_with_interrupt(renderer, stream)
|
|
1211
|
-
|
|
1212
|
-
# Check if we got a session ID back
|
|
1213
|
-
if result and result.get("session_id"):
|
|
1214
|
-
session_id = result["session_id"]
|
|
1215
|
-
|
|
1216
|
-
# Check for spec output
|
|
1217
|
-
if result and result.get("spec"):
|
|
1218
|
-
current_spec = result["spec"]
|
|
1219
|
-
|
|
1220
|
-
# Handle clarifications (may be chained - loop until no more)
|
|
1221
|
-
while True:
|
|
1222
|
-
clarification = result.get("clarification")
|
|
1223
|
-
if not (clarification and session_id):
|
|
1224
|
-
break
|
|
1225
|
-
|
|
1226
|
-
response = _get_clarification_response(clarification)
|
|
1227
|
-
if not response:
|
|
1228
|
-
break
|
|
1229
|
-
|
|
1230
|
-
# Show the user's selection in the chat
|
|
1231
|
-
console.print()
|
|
1232
|
-
console.print(f"[dim]Selected:[/dim] [bold]{response}[/bold]")
|
|
1233
|
-
console.print()
|
|
1234
|
-
|
|
1235
|
-
# Use dedicated clarification answer endpoint
|
|
1236
|
-
try:
|
|
1237
|
-
stream = client.clarification_answer_stream(session_id, response)
|
|
1238
|
-
result = _render_with_interrupt(renderer, stream)
|
|
1239
|
-
|
|
1240
|
-
# Update mode if user chose code
|
|
1241
|
-
if "code" in response.lower():
|
|
1242
|
-
current_mode = AgentMode.CODE
|
|
1243
|
-
except Exception as e:
|
|
1244
|
-
console.print(f"[red]Error continuing session: {e}[/red]")
|
|
1245
|
-
break
|
|
1246
|
-
|
|
1247
|
-
# Handle plan mode entry request (show approval menu)
|
|
1248
|
-
plan_mode_requested = result.get("plan_mode_requested")
|
|
1249
|
-
if plan_mode_requested is not None and session_id:
|
|
1250
|
-
choice, feedback = _show_plan_mode_approval_menu()
|
|
1251
|
-
|
|
1252
|
-
if choice == "approve":
|
|
1253
|
-
current_mode = AgentMode.PLAN
|
|
1254
|
-
console.print()
|
|
1255
|
-
console.print("[bold green]✓ Plan mode activated[/bold green]")
|
|
1256
|
-
console.print()
|
|
1257
|
-
# Use the planmode approve endpoint
|
|
1258
|
-
stream = client.planmode_approve_stream(session_id)
|
|
1259
|
-
result = _render_with_interrupt(renderer, stream)
|
|
1260
|
-
# After approval, check if there's now a plan submitted
|
|
1261
|
-
if result.get("plan_submitted"):
|
|
1262
|
-
plan_submitted = result.get("plan_submitted")
|
|
1263
|
-
elif choice == "reject":
|
|
1264
|
-
# Use the planmode reject endpoint - stay in code mode
|
|
1265
|
-
stream = client.planmode_reject_stream(session_id, feedback)
|
|
1266
|
-
_render_with_interrupt(renderer, stream)
|
|
1267
|
-
|
|
1268
|
-
# Handle plan mode completion (show approval menu)
|
|
1269
|
-
# Only show menu when agent explicitly submits a plan via exit_plan tool
|
|
1270
|
-
plan_submitted = result.get("plan_submitted")
|
|
1271
|
-
should_show_plan_menu = (
|
|
1272
|
-
current_mode == AgentMode.PLAN and
|
|
1273
|
-
session_id and
|
|
1274
|
-
plan_submitted is not None # Agent called exit_plan tool
|
|
1275
|
-
)
|
|
1276
|
-
if should_show_plan_menu:
|
|
1277
|
-
choice, feedback = _show_plan_approval_menu()
|
|
1278
|
-
|
|
1279
|
-
if choice == "approve":
|
|
1280
|
-
current_mode = AgentMode.CODE
|
|
1281
|
-
# Use the plan approve endpoint which properly resets mode on server
|
|
1282
|
-
stream = client.plan_approve_stream(session_id)
|
|
1283
|
-
_render_with_interrupt(renderer, stream)
|
|
1284
|
-
elif choice == "reject":
|
|
1285
|
-
if feedback:
|
|
1286
|
-
# Use the plan reject endpoint which keeps mode as PLAN on server
|
|
1287
|
-
stream = client.plan_reject_stream(session_id, feedback)
|
|
1288
|
-
_render_with_interrupt(renderer, stream)
|
|
1289
|
-
else:
|
|
1290
|
-
console.print("[dim]Plan rejected[/dim]")
|
|
1291
|
-
session_id = None
|
|
1292
|
-
current_spec = None
|
|
1293
|
-
|
|
1294
|
-
console.print()
|
|
1295
|
-
|
|
1296
|
-
except Exception as e:
|
|
1297
|
-
console.print(f"[red]Error: {e}[/red]")
|
|
1298
|
-
|
|
1299
|
-
except KeyboardInterrupt:
|
|
1300
|
-
console.print("\n[dim]Interrupted[/dim]")
|
|
1301
|
-
break
|
|
1302
|
-
except EOFError:
|
|
1303
|
-
break
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
@agent.command("sessions")
|
|
1307
|
-
def list_sessions():
|
|
1308
|
-
"""List active agent sessions."""
|
|
1309
|
-
server = get_server_manager()
|
|
1310
|
-
base_url = server.get_server_url()
|
|
1311
|
-
|
|
1312
|
-
client = EmdashClient(base_url)
|
|
1313
|
-
sessions = client.list_sessions()
|
|
1314
|
-
|
|
1315
|
-
if not sessions:
|
|
1316
|
-
console.print("[dim]No active sessions[/dim]")
|
|
1317
|
-
return
|
|
1318
|
-
|
|
1319
|
-
for s in sessions:
|
|
1320
|
-
console.print(
|
|
1321
|
-
f" {s['session_id'][:8]}... "
|
|
1322
|
-
f"[dim]({s.get('model', 'unknown')}, "
|
|
1323
|
-
f"{s.get('message_count', 0)} messages)[/dim]"
|
|
1324
|
-
)
|
|
10
|
+
__all__ = ["agent", "agent_code"]
|