deepagents-cli 0.0.3__py3-none-any.whl → 0.0.5__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.
Potentially problematic release.
This version of deepagents-cli might be problematic. Click here for more details.
- deepagents_cli/__init__.py +5 -0
- deepagents_cli/__main__.py +6 -0
- deepagents_cli/agent.py +278 -0
- deepagents_cli/cli.py +13 -0
- deepagents_cli/commands.py +89 -0
- deepagents_cli/config.py +138 -0
- deepagents_cli/execution.py +644 -0
- deepagents_cli/file_ops.py +347 -0
- deepagents_cli/input.py +249 -0
- deepagents_cli/main.py +226 -0
- deepagents_cli/py.typed +0 -0
- deepagents_cli/token_utils.py +63 -0
- deepagents_cli/tools.py +140 -0
- deepagents_cli/ui.py +489 -0
- deepagents_cli-0.0.5.dist-info/METADATA +18 -0
- deepagents_cli-0.0.5.dist-info/RECORD +19 -0
- deepagents_cli-0.0.5.dist-info/entry_points.txt +3 -0
- deepagents_cli-0.0.5.dist-info/top_level.txt +1 -0
- deepagents/__init__.py +0 -7
- deepagents/cli.py +0 -567
- deepagents/default_agent_prompt.md +0 -64
- deepagents/graph.py +0 -144
- deepagents/memory/__init__.py +0 -17
- deepagents/memory/backends/__init__.py +0 -15
- deepagents/memory/backends/composite.py +0 -250
- deepagents/memory/backends/filesystem.py +0 -330
- deepagents/memory/backends/state.py +0 -206
- deepagents/memory/backends/store.py +0 -351
- deepagents/memory/backends/utils.py +0 -319
- deepagents/memory/protocol.py +0 -164
- deepagents/middleware/__init__.py +0 -13
- deepagents/middleware/agent_memory.py +0 -207
- deepagents/middleware/filesystem.py +0 -615
- deepagents/middleware/patch_tool_calls.py +0 -44
- deepagents/middleware/subagents.py +0 -481
- deepagents/pretty_cli.py +0 -289
- deepagents_cli-0.0.3.dist-info/METADATA +0 -551
- deepagents_cli-0.0.3.dist-info/RECORD +0 -24
- deepagents_cli-0.0.3.dist-info/entry_points.txt +0 -2
- deepagents_cli-0.0.3.dist-info/licenses/LICENSE +0 -21
- deepagents_cli-0.0.3.dist-info/top_level.txt +0 -1
- {deepagents_cli-0.0.3.dist-info → deepagents_cli-0.0.5.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,347 @@
|
|
|
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)
|
deepagents_cli/input.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Input handling, completers, and prompt session for the CLI."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from prompt_toolkit import PromptSession
|
|
8
|
+
from prompt_toolkit.completion import (
|
|
9
|
+
Completer,
|
|
10
|
+
Completion,
|
|
11
|
+
PathCompleter,
|
|
12
|
+
WordCompleter,
|
|
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, COMMON_BASH_COMMANDS, SessionState, console
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FilePathCompleter(Completer):
|
|
24
|
+
"""File path completer that triggers on @ symbol with case-insensitive matching."""
|
|
25
|
+
|
|
26
|
+
def __init__(self):
|
|
27
|
+
self.path_completer = PathCompleter(expanduser=True)
|
|
28
|
+
|
|
29
|
+
def get_completions(self, document, complete_event):
|
|
30
|
+
"""Get file path completions when @ is detected."""
|
|
31
|
+
text = document.text_before_cursor
|
|
32
|
+
|
|
33
|
+
# Check if we're after an @ symbol
|
|
34
|
+
if "@" in text:
|
|
35
|
+
# Get the part after the last @
|
|
36
|
+
parts = text.split("@")
|
|
37
|
+
if len(parts) >= 2:
|
|
38
|
+
after_at = parts[-1]
|
|
39
|
+
# Create a document for just the path part
|
|
40
|
+
path_doc = Document(after_at, len(after_at))
|
|
41
|
+
|
|
42
|
+
# Get all completions from PathCompleter
|
|
43
|
+
all_completions = list(
|
|
44
|
+
self.path_completer.get_completions(path_doc, complete_event)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# If user has typed something, filter case-insensitively
|
|
48
|
+
if after_at.strip():
|
|
49
|
+
# Extract just the filename part for matching (not the full path)
|
|
50
|
+
search_parts = after_at.split("/")
|
|
51
|
+
search_term = search_parts[-1].lower() if search_parts else ""
|
|
52
|
+
|
|
53
|
+
# Filter completions case-insensitively
|
|
54
|
+
filtered_completions = [
|
|
55
|
+
c for c in all_completions if search_term in c.text.lower()
|
|
56
|
+
]
|
|
57
|
+
else:
|
|
58
|
+
# No search term, show all completions
|
|
59
|
+
filtered_completions = all_completions
|
|
60
|
+
|
|
61
|
+
# Yield filtered completions
|
|
62
|
+
for completion in filtered_completions:
|
|
63
|
+
yield Completion(
|
|
64
|
+
text=completion.text,
|
|
65
|
+
start_position=completion.start_position,
|
|
66
|
+
display=completion.display,
|
|
67
|
+
display_meta=completion.display_meta,
|
|
68
|
+
style=completion.style,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CommandCompleter(Completer):
|
|
73
|
+
"""Command completer for / commands."""
|
|
74
|
+
|
|
75
|
+
def __init__(self):
|
|
76
|
+
self.word_completer = WordCompleter(
|
|
77
|
+
list(COMMANDS.keys()),
|
|
78
|
+
meta_dict=COMMANDS,
|
|
79
|
+
sentence=True,
|
|
80
|
+
ignore_case=True,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def get_completions(self, document, complete_event):
|
|
84
|
+
"""Get command completions when / is at the start."""
|
|
85
|
+
text = document.text
|
|
86
|
+
|
|
87
|
+
# Only complete if line starts with /
|
|
88
|
+
if text.startswith("/"):
|
|
89
|
+
# Remove / for word completion
|
|
90
|
+
cmd_text = text[1:]
|
|
91
|
+
adjusted_doc = Document(
|
|
92
|
+
cmd_text, document.cursor_position - 1 if document.cursor_position > 0 else 0
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
for completion in self.word_completer.get_completions(adjusted_doc, complete_event):
|
|
96
|
+
yield completion
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class BashCompleter(Completer):
|
|
100
|
+
"""Bash command completer for ! commands."""
|
|
101
|
+
|
|
102
|
+
def __init__(self):
|
|
103
|
+
self.word_completer = WordCompleter(
|
|
104
|
+
list(COMMON_BASH_COMMANDS.keys()),
|
|
105
|
+
meta_dict=COMMON_BASH_COMMANDS,
|
|
106
|
+
sentence=True,
|
|
107
|
+
ignore_case=True,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def get_completions(self, document, complete_event):
|
|
111
|
+
"""Get bash command completions when ! is at the start."""
|
|
112
|
+
text = document.text
|
|
113
|
+
|
|
114
|
+
# Only complete if line starts with !
|
|
115
|
+
if text.startswith("!"):
|
|
116
|
+
# Remove ! for word completion
|
|
117
|
+
cmd_text = text[1:]
|
|
118
|
+
adjusted_doc = Document(
|
|
119
|
+
cmd_text, document.cursor_position - 1 if document.cursor_position > 0 else 0
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
for completion in self.word_completer.get_completions(adjusted_doc, complete_event):
|
|
123
|
+
yield completion
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def parse_file_mentions(text: str) -> tuple[str, list[Path]]:
|
|
127
|
+
"""Extract @file mentions and return cleaned text with resolved file paths."""
|
|
128
|
+
pattern = r"@((?:[^\s@]|(?<=\\)\s)+)" # Match @filename, allowing escaped spaces
|
|
129
|
+
matches = re.findall(pattern, text)
|
|
130
|
+
|
|
131
|
+
files = []
|
|
132
|
+
for match in matches:
|
|
133
|
+
# Remove escape characters
|
|
134
|
+
clean_path = match.replace("\\ ", " ")
|
|
135
|
+
path = Path(clean_path).expanduser()
|
|
136
|
+
|
|
137
|
+
# Try to resolve relative to cwd
|
|
138
|
+
if not path.is_absolute():
|
|
139
|
+
path = Path.cwd() / path
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
path = path.resolve()
|
|
143
|
+
if path.exists() and path.is_file():
|
|
144
|
+
files.append(path)
|
|
145
|
+
else:
|
|
146
|
+
console.print(f"[yellow]Warning: File not found: {match}[/yellow]")
|
|
147
|
+
except Exception as e:
|
|
148
|
+
console.print(f"[yellow]Warning: Invalid path {match}: {e}[/yellow]")
|
|
149
|
+
|
|
150
|
+
return text, files
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def get_bottom_toolbar(session_state: SessionState):
|
|
154
|
+
"""Return toolbar function that shows auto-approve status."""
|
|
155
|
+
|
|
156
|
+
def toolbar():
|
|
157
|
+
if session_state.auto_approve:
|
|
158
|
+
# Green background when auto-approve is ON
|
|
159
|
+
return [("class:toolbar-green", "auto-accept ON (CTRL+T to toggle)")]
|
|
160
|
+
# Orange background when manual accept (auto-approve OFF)
|
|
161
|
+
return [("class:toolbar-orange", "manual accept (CTRL+T to toggle)")]
|
|
162
|
+
|
|
163
|
+
return toolbar
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def create_prompt_session(assistant_id: str, session_state: SessionState) -> PromptSession:
|
|
167
|
+
"""Create a configured PromptSession with all features."""
|
|
168
|
+
# Set default editor if not already set
|
|
169
|
+
if "EDITOR" not in os.environ:
|
|
170
|
+
os.environ["EDITOR"] = "nano"
|
|
171
|
+
|
|
172
|
+
# Create key bindings
|
|
173
|
+
kb = KeyBindings()
|
|
174
|
+
|
|
175
|
+
# Bind Ctrl+T to toggle auto-approve
|
|
176
|
+
@kb.add("c-t")
|
|
177
|
+
def _(event):
|
|
178
|
+
"""Toggle auto-approve mode."""
|
|
179
|
+
session_state.toggle_auto_approve()
|
|
180
|
+
# Force UI refresh to update toolbar
|
|
181
|
+
event.app.invalidate()
|
|
182
|
+
|
|
183
|
+
# Bind regular Enter to submit (intuitive behavior)
|
|
184
|
+
@kb.add("enter")
|
|
185
|
+
def _(event):
|
|
186
|
+
"""Enter submits the input, unless completion menu is active."""
|
|
187
|
+
buffer = event.current_buffer
|
|
188
|
+
|
|
189
|
+
# If completion menu is showing, apply the current completion
|
|
190
|
+
if buffer.complete_state:
|
|
191
|
+
# Get the current completion (the highlighted one)
|
|
192
|
+
current_completion = buffer.complete_state.current_completion
|
|
193
|
+
|
|
194
|
+
# If no completion is selected (user hasn't navigated), select and apply the first one
|
|
195
|
+
if not current_completion and buffer.complete_state.completions:
|
|
196
|
+
# Move to the first completion
|
|
197
|
+
buffer.complete_next()
|
|
198
|
+
# Now apply it
|
|
199
|
+
buffer.apply_completion(buffer.complete_state.current_completion)
|
|
200
|
+
elif current_completion:
|
|
201
|
+
# Apply the already-selected completion
|
|
202
|
+
buffer.apply_completion(current_completion)
|
|
203
|
+
else:
|
|
204
|
+
# No completions available, close menu
|
|
205
|
+
buffer.complete_state = None
|
|
206
|
+
# Don't submit if buffer is empty or only whitespace
|
|
207
|
+
elif buffer.text.strip():
|
|
208
|
+
# Normal submit
|
|
209
|
+
buffer.validate_and_handle()
|
|
210
|
+
# If empty, do nothing (don't submit)
|
|
211
|
+
|
|
212
|
+
# Alt+Enter for newlines (press ESC then Enter, or Option+Enter on Mac)
|
|
213
|
+
@kb.add("escape", "enter")
|
|
214
|
+
def _(event):
|
|
215
|
+
"""Alt+Enter inserts a newline for multi-line input."""
|
|
216
|
+
event.current_buffer.insert_text("\n")
|
|
217
|
+
|
|
218
|
+
# Ctrl+E to open in external editor
|
|
219
|
+
@kb.add("c-e")
|
|
220
|
+
def _(event):
|
|
221
|
+
"""Open the current input in an external editor (nano by default)."""
|
|
222
|
+
event.current_buffer.open_in_editor()
|
|
223
|
+
|
|
224
|
+
from prompt_toolkit.styles import Style
|
|
225
|
+
|
|
226
|
+
# Define styles for the toolbar with full-width background colors
|
|
227
|
+
toolbar_style = Style.from_dict(
|
|
228
|
+
{
|
|
229
|
+
"bottom-toolbar": "noreverse", # Disable default reverse video
|
|
230
|
+
"toolbar-green": "bg:#10b981 #000000", # Green for auto-accept ON
|
|
231
|
+
"toolbar-orange": "bg:#f59e0b #000000", # Orange for manual accept
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Create the session
|
|
236
|
+
session = PromptSession(
|
|
237
|
+
message=HTML(f'<style fg="{COLORS["user"]}">></style> '),
|
|
238
|
+
multiline=True, # Keep multiline support but Enter submits
|
|
239
|
+
key_bindings=kb,
|
|
240
|
+
completer=merge_completers([CommandCompleter(), BashCompleter(), FilePathCompleter()]),
|
|
241
|
+
editing_mode=EditingMode.EMACS,
|
|
242
|
+
complete_while_typing=True, # Show completions as you type
|
|
243
|
+
mouse_support=False,
|
|
244
|
+
enable_open_in_editor=True, # Allow Ctrl+X Ctrl+E to open external editor
|
|
245
|
+
bottom_toolbar=get_bottom_toolbar(session_state), # Persistent status bar at bottom
|
|
246
|
+
style=toolbar_style, # Apply toolbar styling
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return session
|