deepagents 0.2.5__py3-none-any.whl → 0.2.6__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.
- deepagents/backends/composite.py +37 -2
- deepagents/backends/protocol.py +48 -0
- deepagents/backends/sandbox.py +341 -0
- deepagents/backends/store.py +3 -11
- deepagents/graph.py +7 -3
- deepagents/middleware/filesystem.py +224 -21
- deepagents/middleware/subagents.py +7 -4
- {deepagents-0.2.5.dist-info → deepagents-0.2.6.dist-info}/METADATA +5 -4
- deepagents-0.2.6.dist-info/RECORD +19 -0
- deepagents-0.2.6.dist-info/top_level.txt +1 -0
- deepagents-0.2.5.dist-info/RECORD +0 -38
- deepagents-0.2.5.dist-info/licenses/LICENSE +0 -21
- deepagents-0.2.5.dist-info/top_level.txt +0 -2
- deepagents-cli/README.md +0 -3
- deepagents-cli/deepagents_cli/README.md +0 -196
- deepagents-cli/deepagents_cli/__init__.py +0 -5
- deepagents-cli/deepagents_cli/__main__.py +0 -6
- deepagents-cli/deepagents_cli/agent.py +0 -278
- deepagents-cli/deepagents_cli/agent_memory.py +0 -226
- deepagents-cli/deepagents_cli/commands.py +0 -89
- deepagents-cli/deepagents_cli/config.py +0 -118
- deepagents-cli/deepagents_cli/default_agent_prompt.md +0 -110
- deepagents-cli/deepagents_cli/execution.py +0 -636
- deepagents-cli/deepagents_cli/file_ops.py +0 -347
- deepagents-cli/deepagents_cli/input.py +0 -270
- deepagents-cli/deepagents_cli/main.py +0 -226
- deepagents-cli/deepagents_cli/py.typed +0 -0
- deepagents-cli/deepagents_cli/token_utils.py +0 -63
- deepagents-cli/deepagents_cli/tools.py +0 -140
- deepagents-cli/deepagents_cli/ui.py +0 -489
- deepagents-cli/tests/test_file_ops.py +0 -119
- deepagents-cli/tests/test_placeholder.py +0 -5
- {deepagents-0.2.5.dist-info → deepagents-0.2.6.dist-info}/WHEEL +0 -0
|
@@ -1,347 +0,0 @@
|
|
|
1
|
-
"""Helpers for tracking file operations and computing diffs for CLI display."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import difflib
|
|
6
|
-
from dataclasses import dataclass, field
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
from typing import Any, Literal
|
|
9
|
-
|
|
10
|
-
from deepagents.backends.utils import perform_string_replacement
|
|
11
|
-
|
|
12
|
-
FileOpStatus = Literal["pending", "success", "error"]
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@dataclass
|
|
16
|
-
class ApprovalPreview:
|
|
17
|
-
"""Data used to render HITL previews."""
|
|
18
|
-
|
|
19
|
-
title: str
|
|
20
|
-
details: list[str]
|
|
21
|
-
diff: str | None = None
|
|
22
|
-
diff_title: str | None = None
|
|
23
|
-
error: str | None = None
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def _safe_read(path: Path) -> str | None:
|
|
27
|
-
"""Read file content, returning None on failure."""
|
|
28
|
-
try:
|
|
29
|
-
return path.read_text()
|
|
30
|
-
except (OSError, UnicodeDecodeError):
|
|
31
|
-
return None
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _count_lines(text: str) -> int:
|
|
35
|
-
"""Count lines in text, treating empty strings as zero lines."""
|
|
36
|
-
if not text:
|
|
37
|
-
return 0
|
|
38
|
-
return len(text.splitlines())
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def compute_unified_diff(
|
|
42
|
-
before: str,
|
|
43
|
-
after: str,
|
|
44
|
-
display_path: str,
|
|
45
|
-
*,
|
|
46
|
-
max_lines: int | None = 800,
|
|
47
|
-
) -> str | None:
|
|
48
|
-
"""Compute a unified diff between before and after content."""
|
|
49
|
-
before_lines = before.splitlines()
|
|
50
|
-
after_lines = after.splitlines()
|
|
51
|
-
diff_lines = list(
|
|
52
|
-
difflib.unified_diff(
|
|
53
|
-
before_lines,
|
|
54
|
-
after_lines,
|
|
55
|
-
fromfile=f"{display_path} (before)",
|
|
56
|
-
tofile=f"{display_path} (after)",
|
|
57
|
-
lineterm="",
|
|
58
|
-
)
|
|
59
|
-
)
|
|
60
|
-
if not diff_lines:
|
|
61
|
-
return None
|
|
62
|
-
if max_lines is not None and len(diff_lines) > max_lines:
|
|
63
|
-
truncated = diff_lines[: max_lines - 1]
|
|
64
|
-
truncated.append("... (diff truncated)")
|
|
65
|
-
return "\n".join(truncated)
|
|
66
|
-
return "\n".join(diff_lines)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
@dataclass
|
|
70
|
-
class FileOpMetrics:
|
|
71
|
-
"""Line and byte level metrics for a file operation."""
|
|
72
|
-
|
|
73
|
-
lines_read: int = 0
|
|
74
|
-
start_line: int | None = None
|
|
75
|
-
end_line: int | None = None
|
|
76
|
-
lines_written: int = 0
|
|
77
|
-
lines_added: int = 0
|
|
78
|
-
lines_removed: int = 0
|
|
79
|
-
bytes_written: int = 0
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
@dataclass
|
|
83
|
-
class FileOperationRecord:
|
|
84
|
-
"""Track a single filesystem tool call."""
|
|
85
|
-
|
|
86
|
-
tool_name: str
|
|
87
|
-
display_path: str
|
|
88
|
-
physical_path: Path | None
|
|
89
|
-
tool_call_id: str | None
|
|
90
|
-
args: dict[str, Any] = field(default_factory=dict)
|
|
91
|
-
status: FileOpStatus = "pending"
|
|
92
|
-
error: str | None = None
|
|
93
|
-
metrics: FileOpMetrics = field(default_factory=FileOpMetrics)
|
|
94
|
-
diff: str | None = None
|
|
95
|
-
before_content: str | None = None
|
|
96
|
-
after_content: str | None = None
|
|
97
|
-
read_output: str | None = None
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def resolve_physical_path(path_str: str | None, assistant_id: str | None) -> Path | None:
|
|
101
|
-
"""Convert a virtual/relative path to a physical filesystem path."""
|
|
102
|
-
if not path_str:
|
|
103
|
-
return None
|
|
104
|
-
try:
|
|
105
|
-
if assistant_id and path_str.startswith("/memories/"):
|
|
106
|
-
agent_dir = Path.home() / ".deepagents" / assistant_id
|
|
107
|
-
suffix = path_str.removeprefix("/memories/").lstrip("/")
|
|
108
|
-
return (agent_dir / suffix).resolve()
|
|
109
|
-
path = Path(path_str)
|
|
110
|
-
if path.is_absolute():
|
|
111
|
-
return path
|
|
112
|
-
return (Path.cwd() / path).resolve()
|
|
113
|
-
except (OSError, ValueError):
|
|
114
|
-
return None
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def format_display_path(path_str: str | None) -> str:
|
|
118
|
-
"""Format a path for display."""
|
|
119
|
-
if not path_str:
|
|
120
|
-
return "(unknown)"
|
|
121
|
-
try:
|
|
122
|
-
path = Path(path_str)
|
|
123
|
-
if path.is_absolute():
|
|
124
|
-
return path.name or str(path)
|
|
125
|
-
return str(path)
|
|
126
|
-
except (OSError, ValueError):
|
|
127
|
-
return str(path_str)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def build_approval_preview(
|
|
131
|
-
tool_name: str,
|
|
132
|
-
args: dict[str, Any] | None,
|
|
133
|
-
assistant_id: str | None,
|
|
134
|
-
) -> ApprovalPreview | None:
|
|
135
|
-
"""Collect summary info and diff for HITL approvals."""
|
|
136
|
-
if args is None:
|
|
137
|
-
return None
|
|
138
|
-
|
|
139
|
-
path_str = str(args.get("file_path") or args.get("path") or "")
|
|
140
|
-
display_path = format_display_path(path_str)
|
|
141
|
-
physical_path = resolve_physical_path(path_str, assistant_id)
|
|
142
|
-
|
|
143
|
-
if tool_name == "write_file":
|
|
144
|
-
content = str(args.get("content", ""))
|
|
145
|
-
before = _safe_read(physical_path) if physical_path and physical_path.exists() else ""
|
|
146
|
-
after = content
|
|
147
|
-
diff = compute_unified_diff(before or "", after, display_path, max_lines=None)
|
|
148
|
-
additions = 0
|
|
149
|
-
if diff:
|
|
150
|
-
additions = sum(
|
|
151
|
-
1
|
|
152
|
-
for line in diff.splitlines()
|
|
153
|
-
if line.startswith("+") and not line.startswith("+++")
|
|
154
|
-
)
|
|
155
|
-
total_lines = _count_lines(after)
|
|
156
|
-
details = [
|
|
157
|
-
f"File: {path_str}",
|
|
158
|
-
"Action: Create new file" + (" (overwrites existing content)" if before else ""),
|
|
159
|
-
f"Lines to write: {additions or total_lines}",
|
|
160
|
-
f"Bytes to write: {len(after.encode('utf-8'))}",
|
|
161
|
-
]
|
|
162
|
-
return ApprovalPreview(
|
|
163
|
-
title=f"Write {display_path}",
|
|
164
|
-
details=details,
|
|
165
|
-
diff=diff,
|
|
166
|
-
diff_title=f"Diff {display_path}",
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
if tool_name == "edit_file":
|
|
170
|
-
if physical_path is None:
|
|
171
|
-
return ApprovalPreview(
|
|
172
|
-
title=f"Update {display_path}",
|
|
173
|
-
details=[f"File: {path_str}", "Action: Replace text"],
|
|
174
|
-
error="Unable to resolve file path.",
|
|
175
|
-
)
|
|
176
|
-
before = _safe_read(physical_path)
|
|
177
|
-
if before is None:
|
|
178
|
-
return ApprovalPreview(
|
|
179
|
-
title=f"Update {display_path}",
|
|
180
|
-
details=[f"File: {path_str}", "Action: Replace text"],
|
|
181
|
-
error="Unable to read current file contents.",
|
|
182
|
-
)
|
|
183
|
-
old_string = str(args.get("old_string", ""))
|
|
184
|
-
new_string = str(args.get("new_string", ""))
|
|
185
|
-
replace_all = bool(args.get("replace_all", False))
|
|
186
|
-
replacement = perform_string_replacement(before, old_string, new_string, replace_all)
|
|
187
|
-
if isinstance(replacement, str):
|
|
188
|
-
return ApprovalPreview(
|
|
189
|
-
title=f"Update {display_path}",
|
|
190
|
-
details=[f"File: {path_str}", "Action: Replace text"],
|
|
191
|
-
error=replacement,
|
|
192
|
-
)
|
|
193
|
-
after, occurrences = replacement
|
|
194
|
-
diff = compute_unified_diff(before, after, display_path, max_lines=None)
|
|
195
|
-
additions = 0
|
|
196
|
-
deletions = 0
|
|
197
|
-
if diff:
|
|
198
|
-
additions = sum(
|
|
199
|
-
1
|
|
200
|
-
for line in diff.splitlines()
|
|
201
|
-
if line.startswith("+") and not line.startswith("+++")
|
|
202
|
-
)
|
|
203
|
-
deletions = sum(
|
|
204
|
-
1
|
|
205
|
-
for line in diff.splitlines()
|
|
206
|
-
if line.startswith("-") and not line.startswith("---")
|
|
207
|
-
)
|
|
208
|
-
details = [
|
|
209
|
-
f"File: {path_str}",
|
|
210
|
-
f"Action: Replace text ({'all occurrences' if replace_all else 'single occurrence'})",
|
|
211
|
-
f"Occurrences matched: {occurrences}",
|
|
212
|
-
f"Lines changed: +{additions} / -{deletions}",
|
|
213
|
-
]
|
|
214
|
-
return ApprovalPreview(
|
|
215
|
-
title=f"Update {display_path}",
|
|
216
|
-
details=details,
|
|
217
|
-
diff=diff,
|
|
218
|
-
diff_title=f"Diff {display_path}",
|
|
219
|
-
)
|
|
220
|
-
|
|
221
|
-
return None
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
class FileOpTracker:
|
|
225
|
-
"""Collect file operation metrics during a CLI interaction."""
|
|
226
|
-
|
|
227
|
-
def __init__(self, *, assistant_id: str | None) -> None:
|
|
228
|
-
self.assistant_id = assistant_id
|
|
229
|
-
self.active: dict[str | None, FileOperationRecord] = {}
|
|
230
|
-
self.completed: list[FileOperationRecord] = []
|
|
231
|
-
|
|
232
|
-
def start_operation(
|
|
233
|
-
self, tool_name: str, args: dict[str, Any], tool_call_id: str | None
|
|
234
|
-
) -> None:
|
|
235
|
-
if tool_name not in {"read_file", "write_file", "edit_file"}:
|
|
236
|
-
return
|
|
237
|
-
path_str = str(args.get("file_path") or args.get("path") or "")
|
|
238
|
-
display_path = format_display_path(path_str)
|
|
239
|
-
record = FileOperationRecord(
|
|
240
|
-
tool_name=tool_name,
|
|
241
|
-
display_path=display_path,
|
|
242
|
-
physical_path=resolve_physical_path(path_str, self.assistant_id),
|
|
243
|
-
tool_call_id=tool_call_id,
|
|
244
|
-
args=args,
|
|
245
|
-
)
|
|
246
|
-
if tool_name in {"write_file", "edit_file"} and record.physical_path:
|
|
247
|
-
record.before_content = _safe_read(record.physical_path) or ""
|
|
248
|
-
self.active[tool_call_id] = record
|
|
249
|
-
|
|
250
|
-
def complete_with_message(self, tool_message: Any) -> FileOperationRecord | None:
|
|
251
|
-
tool_call_id = getattr(tool_message, "tool_call_id", None)
|
|
252
|
-
record = self.active.get(tool_call_id)
|
|
253
|
-
if record is None:
|
|
254
|
-
return None
|
|
255
|
-
|
|
256
|
-
content = tool_message.content
|
|
257
|
-
if isinstance(content, list):
|
|
258
|
-
# Some tool messages may return list segments; join them for analysis.
|
|
259
|
-
joined = []
|
|
260
|
-
for item in content:
|
|
261
|
-
if isinstance(item, str):
|
|
262
|
-
joined.append(item)
|
|
263
|
-
else:
|
|
264
|
-
joined.append(str(item))
|
|
265
|
-
content_text = "\n".join(joined)
|
|
266
|
-
else:
|
|
267
|
-
content_text = str(content) if content is not None else ""
|
|
268
|
-
|
|
269
|
-
if getattr(
|
|
270
|
-
tool_message, "status", "success"
|
|
271
|
-
) != "success" or content_text.lower().startswith("error"):
|
|
272
|
-
record.status = "error"
|
|
273
|
-
record.error = content_text
|
|
274
|
-
self._finalize(record)
|
|
275
|
-
return record
|
|
276
|
-
|
|
277
|
-
record.status = "success"
|
|
278
|
-
|
|
279
|
-
if record.tool_name == "read_file":
|
|
280
|
-
record.read_output = content_text
|
|
281
|
-
lines = _count_lines(content_text)
|
|
282
|
-
record.metrics.lines_read = lines
|
|
283
|
-
offset = record.args.get("offset")
|
|
284
|
-
limit = record.args.get("limit")
|
|
285
|
-
if isinstance(offset, int):
|
|
286
|
-
record.metrics.start_line = offset + 1
|
|
287
|
-
if lines:
|
|
288
|
-
record.metrics.end_line = offset + lines
|
|
289
|
-
elif lines:
|
|
290
|
-
record.metrics.start_line = 1
|
|
291
|
-
record.metrics.end_line = lines
|
|
292
|
-
if isinstance(limit, int) and lines > limit:
|
|
293
|
-
record.metrics.end_line = (record.metrics.start_line or 1) + limit - 1
|
|
294
|
-
else:
|
|
295
|
-
self._populate_after_content(record)
|
|
296
|
-
if record.after_content is None:
|
|
297
|
-
record.status = "error"
|
|
298
|
-
record.error = "Could not read updated file content."
|
|
299
|
-
self._finalize(record)
|
|
300
|
-
return record
|
|
301
|
-
record.metrics.lines_written = _count_lines(record.after_content)
|
|
302
|
-
before_lines = _count_lines(record.before_content or "")
|
|
303
|
-
diff = compute_unified_diff(
|
|
304
|
-
record.before_content or "",
|
|
305
|
-
record.after_content,
|
|
306
|
-
record.display_path,
|
|
307
|
-
max_lines=None,
|
|
308
|
-
)
|
|
309
|
-
record.diff = diff
|
|
310
|
-
if diff:
|
|
311
|
-
additions = sum(
|
|
312
|
-
1
|
|
313
|
-
for line in diff.splitlines()
|
|
314
|
-
if line.startswith("+") and not line.startswith("+++")
|
|
315
|
-
)
|
|
316
|
-
deletions = sum(
|
|
317
|
-
1
|
|
318
|
-
for line in diff.splitlines()
|
|
319
|
-
if line.startswith("-") and not line.startswith("---")
|
|
320
|
-
)
|
|
321
|
-
record.metrics.lines_added = additions
|
|
322
|
-
record.metrics.lines_removed = deletions
|
|
323
|
-
elif record.tool_name == "write_file" and (record.before_content or "") == "":
|
|
324
|
-
record.metrics.lines_added = record.metrics.lines_written
|
|
325
|
-
record.metrics.bytes_written = len(record.after_content.encode("utf-8"))
|
|
326
|
-
if record.diff is None and (record.before_content or "") != record.after_content:
|
|
327
|
-
record.diff = compute_unified_diff(
|
|
328
|
-
record.before_content or "",
|
|
329
|
-
record.after_content,
|
|
330
|
-
record.display_path,
|
|
331
|
-
max_lines=None,
|
|
332
|
-
)
|
|
333
|
-
if record.diff is None and before_lines != record.metrics.lines_written:
|
|
334
|
-
record.metrics.lines_added = max(record.metrics.lines_written - before_lines, 0)
|
|
335
|
-
|
|
336
|
-
self._finalize(record)
|
|
337
|
-
return record
|
|
338
|
-
|
|
339
|
-
def _populate_after_content(self, record: FileOperationRecord) -> None:
|
|
340
|
-
if record.physical_path is None:
|
|
341
|
-
record.after_content = None
|
|
342
|
-
return
|
|
343
|
-
record.after_content = _safe_read(record.physical_path)
|
|
344
|
-
|
|
345
|
-
def _finalize(self, record: FileOperationRecord) -> None:
|
|
346
|
-
self.completed.append(record)
|
|
347
|
-
self.active.pop(record.tool_call_id, None)
|
|
@@ -1,270 +0,0 @@
|
|
|
1
|
-
"""Input handling, completers, and prompt session for the CLI."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import re
|
|
5
|
-
from collections.abc import Callable
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
from prompt_toolkit import PromptSession
|
|
9
|
-
from prompt_toolkit.completion import (
|
|
10
|
-
Completer,
|
|
11
|
-
Completion,
|
|
12
|
-
PathCompleter,
|
|
13
|
-
merge_completers,
|
|
14
|
-
)
|
|
15
|
-
from prompt_toolkit.document import Document
|
|
16
|
-
from prompt_toolkit.enums import EditingMode
|
|
17
|
-
from prompt_toolkit.formatted_text import HTML
|
|
18
|
-
from prompt_toolkit.key_binding import KeyBindings
|
|
19
|
-
|
|
20
|
-
from .config import COLORS, COMMANDS, SessionState, console
|
|
21
|
-
|
|
22
|
-
# Regex patterns for context-aware completion
|
|
23
|
-
AT_MENTION_RE = re.compile(r"@(?P<path>(?:[^\s@]|(?<=\\)\s)*)$")
|
|
24
|
-
SLASH_COMMAND_RE = re.compile(r"^/(?P<command>[a-z]*)$")
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class FilePathCompleter(Completer):
|
|
28
|
-
"""Activate filesystem completion only when cursor is after '@'."""
|
|
29
|
-
|
|
30
|
-
def __init__(self):
|
|
31
|
-
self.path_completer = PathCompleter(
|
|
32
|
-
expanduser=True,
|
|
33
|
-
min_input_len=0,
|
|
34
|
-
only_directories=False,
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
def get_completions(self, document, complete_event):
|
|
38
|
-
"""Get file path completions when @ is detected."""
|
|
39
|
-
text = document.text_before_cursor
|
|
40
|
-
|
|
41
|
-
# Use regex to detect @path pattern at end of line
|
|
42
|
-
m = AT_MENTION_RE.search(text)
|
|
43
|
-
if not m:
|
|
44
|
-
return # Not in an @path context
|
|
45
|
-
|
|
46
|
-
path_fragment = m.group("path")
|
|
47
|
-
|
|
48
|
-
# Unescape the path for PathCompleter (it doesn't understand escape sequences)
|
|
49
|
-
unescaped_fragment = path_fragment.replace("\\ ", " ")
|
|
50
|
-
|
|
51
|
-
# Strip trailing backslash if present (user is in the process of typing an escape)
|
|
52
|
-
unescaped_fragment = unescaped_fragment.removesuffix("\\")
|
|
53
|
-
|
|
54
|
-
# Create temporary document for the unescaped path fragment
|
|
55
|
-
temp_doc = Document(text=unescaped_fragment, cursor_position=len(unescaped_fragment))
|
|
56
|
-
|
|
57
|
-
# Get completions from PathCompleter and use its start_position
|
|
58
|
-
# PathCompleter returns suffix text with start_position=0 (insert at cursor)
|
|
59
|
-
for comp in self.path_completer.get_completions(temp_doc, complete_event):
|
|
60
|
-
# Add trailing / for directories so users can continue navigating
|
|
61
|
-
completed_path = Path(unescaped_fragment + comp.text).expanduser()
|
|
62
|
-
# Re-escape spaces in the completion text for the command line
|
|
63
|
-
completion_text = comp.text.replace(" ", "\\ ")
|
|
64
|
-
if completed_path.is_dir() and not completion_text.endswith("/"):
|
|
65
|
-
completion_text += "/"
|
|
66
|
-
|
|
67
|
-
yield Completion(
|
|
68
|
-
text=completion_text,
|
|
69
|
-
start_position=comp.start_position, # Use PathCompleter's position (usually 0)
|
|
70
|
-
display=comp.display,
|
|
71
|
-
display_meta=comp.display_meta,
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
class CommandCompleter(Completer):
|
|
76
|
-
"""Activate command completion only when line starts with '/'."""
|
|
77
|
-
|
|
78
|
-
def get_completions(self, document, complete_event):
|
|
79
|
-
"""Get command completions when / is at the start."""
|
|
80
|
-
text = document.text_before_cursor
|
|
81
|
-
|
|
82
|
-
# Use regex to detect /command pattern at start of line
|
|
83
|
-
m = SLASH_COMMAND_RE.match(text)
|
|
84
|
-
if not m:
|
|
85
|
-
return # Not in a /command context
|
|
86
|
-
|
|
87
|
-
command_fragment = m.group("command")
|
|
88
|
-
|
|
89
|
-
# Match commands that start with the fragment (case-insensitive)
|
|
90
|
-
for cmd_name, cmd_desc in COMMANDS.items():
|
|
91
|
-
if cmd_name.startswith(command_fragment.lower()):
|
|
92
|
-
yield Completion(
|
|
93
|
-
text=cmd_name,
|
|
94
|
-
start_position=-len(command_fragment), # Fixed position for original document
|
|
95
|
-
display=cmd_name,
|
|
96
|
-
display_meta=cmd_desc,
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def parse_file_mentions(text: str) -> tuple[str, list[Path]]:
|
|
101
|
-
"""Extract @file mentions and return cleaned text with resolved file paths."""
|
|
102
|
-
pattern = r"@((?:[^\s@]|(?<=\\)\s)+)" # Match @filename, allowing escaped spaces
|
|
103
|
-
matches = re.findall(pattern, text)
|
|
104
|
-
|
|
105
|
-
files = []
|
|
106
|
-
for match in matches:
|
|
107
|
-
# Remove escape characters
|
|
108
|
-
clean_path = match.replace("\\ ", " ")
|
|
109
|
-
path = Path(clean_path).expanduser()
|
|
110
|
-
|
|
111
|
-
# Try to resolve relative to cwd
|
|
112
|
-
if not path.is_absolute():
|
|
113
|
-
path = Path.cwd() / path
|
|
114
|
-
|
|
115
|
-
try:
|
|
116
|
-
path = path.resolve()
|
|
117
|
-
if path.exists() and path.is_file():
|
|
118
|
-
files.append(path)
|
|
119
|
-
else:
|
|
120
|
-
console.print(f"[yellow]Warning: File not found: {match}[/yellow]")
|
|
121
|
-
except Exception as e:
|
|
122
|
-
console.print(f"[yellow]Warning: Invalid path {match}: {e}[/yellow]")
|
|
123
|
-
|
|
124
|
-
return text, files
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def get_bottom_toolbar(
|
|
128
|
-
session_state: SessionState, session_ref: dict
|
|
129
|
-
) -> Callable[[], list[tuple[str, str]]]:
|
|
130
|
-
"""Return toolbar function that shows auto-approve status and BASH MODE."""
|
|
131
|
-
|
|
132
|
-
def toolbar() -> list[tuple[str, str]]:
|
|
133
|
-
parts = []
|
|
134
|
-
|
|
135
|
-
# Check if we're in BASH mode (input starts with !)
|
|
136
|
-
try:
|
|
137
|
-
session = session_ref.get("session")
|
|
138
|
-
if session:
|
|
139
|
-
current_text = session.default_buffer.text
|
|
140
|
-
if current_text.startswith("!"):
|
|
141
|
-
parts.append(("bg:#ff1493 fg:#ffffff bold", " BASH MODE "))
|
|
142
|
-
parts.append(("", " | "))
|
|
143
|
-
except (AttributeError, TypeError):
|
|
144
|
-
# Silently ignore - toolbar is non-critical and called frequently
|
|
145
|
-
pass
|
|
146
|
-
|
|
147
|
-
# Base status message
|
|
148
|
-
if session_state.auto_approve:
|
|
149
|
-
base_msg = "auto-accept ON (CTRL+T to toggle)"
|
|
150
|
-
base_class = "class:toolbar-green"
|
|
151
|
-
else:
|
|
152
|
-
base_msg = "manual accept (CTRL+T to toggle)"
|
|
153
|
-
base_class = "class:toolbar-orange"
|
|
154
|
-
|
|
155
|
-
parts.append((base_class, base_msg))
|
|
156
|
-
|
|
157
|
-
return parts
|
|
158
|
-
|
|
159
|
-
return toolbar
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def create_prompt_session(assistant_id: str, session_state: SessionState) -> PromptSession:
|
|
163
|
-
"""Create a configured PromptSession with all features."""
|
|
164
|
-
# Set default editor if not already set
|
|
165
|
-
if "EDITOR" not in os.environ:
|
|
166
|
-
os.environ["EDITOR"] = "nano"
|
|
167
|
-
|
|
168
|
-
# Create key bindings
|
|
169
|
-
kb = KeyBindings()
|
|
170
|
-
|
|
171
|
-
# Bind Ctrl+T to toggle auto-approve
|
|
172
|
-
@kb.add("c-t")
|
|
173
|
-
def _(event):
|
|
174
|
-
"""Toggle auto-approve mode."""
|
|
175
|
-
session_state.toggle_auto_approve()
|
|
176
|
-
# Force UI refresh to update toolbar
|
|
177
|
-
event.app.invalidate()
|
|
178
|
-
|
|
179
|
-
# Bind regular Enter to submit (intuitive behavior)
|
|
180
|
-
@kb.add("enter")
|
|
181
|
-
def _(event):
|
|
182
|
-
"""Enter submits the input, unless completion menu is active."""
|
|
183
|
-
buffer = event.current_buffer
|
|
184
|
-
|
|
185
|
-
# If completion menu is showing, apply the current completion
|
|
186
|
-
if buffer.complete_state:
|
|
187
|
-
# Get the current completion (the highlighted one)
|
|
188
|
-
current_completion = buffer.complete_state.current_completion
|
|
189
|
-
|
|
190
|
-
# If no completion is selected (user hasn't navigated), select and apply the first one
|
|
191
|
-
if not current_completion and buffer.complete_state.completions:
|
|
192
|
-
# Move to the first completion
|
|
193
|
-
buffer.complete_next()
|
|
194
|
-
# Now apply it
|
|
195
|
-
buffer.apply_completion(buffer.complete_state.current_completion)
|
|
196
|
-
elif current_completion:
|
|
197
|
-
# Apply the already-selected completion
|
|
198
|
-
buffer.apply_completion(current_completion)
|
|
199
|
-
else:
|
|
200
|
-
# No completions available, close menu
|
|
201
|
-
buffer.complete_state = None
|
|
202
|
-
# Don't submit if buffer is empty or only whitespace
|
|
203
|
-
elif buffer.text.strip():
|
|
204
|
-
# Normal submit
|
|
205
|
-
buffer.validate_and_handle()
|
|
206
|
-
# If empty, do nothing (don't submit)
|
|
207
|
-
|
|
208
|
-
# Alt+Enter for newlines (press ESC then Enter, or Option+Enter on Mac)
|
|
209
|
-
@kb.add("escape", "enter")
|
|
210
|
-
def _(event):
|
|
211
|
-
"""Alt+Enter inserts a newline for multi-line input."""
|
|
212
|
-
event.current_buffer.insert_text("\n")
|
|
213
|
-
|
|
214
|
-
# Ctrl+E to open in external editor
|
|
215
|
-
@kb.add("c-e")
|
|
216
|
-
def _(event):
|
|
217
|
-
"""Open the current input in an external editor (nano by default)."""
|
|
218
|
-
event.current_buffer.open_in_editor()
|
|
219
|
-
|
|
220
|
-
# Backspace handler to retrigger completions after deletion
|
|
221
|
-
@kb.add("backspace")
|
|
222
|
-
def _(event):
|
|
223
|
-
"""Handle backspace and retrigger completion if in @ or / context."""
|
|
224
|
-
buffer = event.current_buffer
|
|
225
|
-
|
|
226
|
-
# Perform the normal backspace action
|
|
227
|
-
buffer.delete_before_cursor(count=1)
|
|
228
|
-
|
|
229
|
-
# Check if we're in a completion context (@ or /)
|
|
230
|
-
text = buffer.document.text_before_cursor
|
|
231
|
-
if AT_MENTION_RE.search(text) or SLASH_COMMAND_RE.match(text):
|
|
232
|
-
# Retrigger completion
|
|
233
|
-
buffer.start_completion(select_first=False)
|
|
234
|
-
|
|
235
|
-
from prompt_toolkit.styles import Style
|
|
236
|
-
|
|
237
|
-
# Define styles for the toolbar with full-width background colors
|
|
238
|
-
toolbar_style = Style.from_dict(
|
|
239
|
-
{
|
|
240
|
-
"bottom-toolbar": "noreverse", # Disable default reverse video
|
|
241
|
-
"toolbar-green": "bg:#10b981 #000000", # Green for auto-accept ON
|
|
242
|
-
"toolbar-orange": "bg:#f59e0b #000000", # Orange for manual accept
|
|
243
|
-
}
|
|
244
|
-
)
|
|
245
|
-
|
|
246
|
-
# Create session reference dict for toolbar to access session
|
|
247
|
-
session_ref = {}
|
|
248
|
-
|
|
249
|
-
# Create the session
|
|
250
|
-
session = PromptSession(
|
|
251
|
-
message=HTML(f'<style fg="{COLORS["user"]}">></style> '),
|
|
252
|
-
multiline=True, # Keep multiline support but Enter submits
|
|
253
|
-
key_bindings=kb,
|
|
254
|
-
completer=merge_completers([CommandCompleter(), FilePathCompleter()]),
|
|
255
|
-
editing_mode=EditingMode.EMACS,
|
|
256
|
-
complete_while_typing=True, # Show completions as you type
|
|
257
|
-
complete_in_thread=True, # Async completion prevents menu freezing
|
|
258
|
-
mouse_support=False,
|
|
259
|
-
enable_open_in_editor=True, # Allow Ctrl+X Ctrl+E to open external editor
|
|
260
|
-
bottom_toolbar=get_bottom_toolbar(
|
|
261
|
-
session_state, session_ref
|
|
262
|
-
), # Persistent status bar at bottom
|
|
263
|
-
style=toolbar_style, # Apply toolbar styling
|
|
264
|
-
reserve_space_for_menu=7, # Reserve space for completion menu to show 5-6 results
|
|
265
|
-
)
|
|
266
|
-
|
|
267
|
-
# Store session reference for toolbar to access
|
|
268
|
-
session_ref["session"] = session
|
|
269
|
-
|
|
270
|
-
return session
|