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