deep-agent-cli 0.1.0__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.
- agent/__init__.py +42 -0
- agent/attachments.py +303 -0
- agent/bootstrap.py +44 -0
- agent/cancel.py +107 -0
- agent/cli/__init__.py +5 -0
- agent/cli/app.py +1768 -0
- agent/cli/clipboard.py +224 -0
- agent/cli/commands.py +94 -0
- agent/cli/gitinfo.py +84 -0
- agent/cli/input.py +65 -0
- agent/cli/interactions.py +187 -0
- agent/cli/main.py +124 -0
- agent/cli/previews.py +710 -0
- agent/cli/rendering.py +770 -0
- agent/cli/session_controller.py +221 -0
- agent/cli/state.py +326 -0
- agent/config.example.yaml +76 -0
- agent/config.py +528 -0
- agent/control.py +171 -0
- agent/factory.py +232 -0
- agent/file_mutation.py +5 -0
- agent/llm.py +339 -0
- agent/middleware/__init__.py +9 -0
- agent/middleware/attachments.py +31 -0
- agent/middleware/cancel_tools.py +39 -0
- agent/middleware/pause.py +18 -0
- agent/middleware/recovery.py +65 -0
- agent/middleware/steering.py +35 -0
- agent/middleware/tool_arg_hints.py +128 -0
- agent/middleware/workspace_filesystem.py +38 -0
- agent/middleware/write_operation.py +60 -0
- agent/network.py +30 -0
- agent/permission.py +80 -0
- agent/runner.py +1393 -0
- agent/sandbox.py +699 -0
- agent/session.py +431 -0
- agent/session_lock.py +223 -0
- agent/session_runtime.py +209 -0
- agent/stream.py +168 -0
- agent/tools/__init__.py +9 -0
- agent/tools/examples.py +30 -0
- agent/tools/execute.py +73 -0
- agent/tools/human_input.py +170 -0
- agent/tools/human_interaction.py +101 -0
- agent/tools/web_search.py +131 -0
- deep_agent_cli-0.1.0.dist-info/METADATA +408 -0
- deep_agent_cli-0.1.0.dist-info/RECORD +50 -0
- deep_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- deep_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- deep_agent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
agent/cli/clipboard.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Small, dependency-free system clipboard adapter for the TUI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ClipboardError(RuntimeError):
|
|
16
|
+
"""Raised when no usable system clipboard transport is available."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ClipboardText:
|
|
21
|
+
text: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ClipboardImage:
|
|
26
|
+
"""Clipboard contains a bitmap; export is deliberately deferred."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ClipboardFiles:
|
|
31
|
+
paths: tuple[str, ...]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ClipboardUnavailable:
|
|
36
|
+
reason: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
ClipboardContent = ClipboardText | ClipboardImage | ClipboardFiles | ClipboardUnavailable
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ClipboardAdapter:
|
|
43
|
+
"""Inspect first, then export an image only after the caller's capability gate."""
|
|
44
|
+
|
|
45
|
+
def inspect(self) -> ClipboardContent:
|
|
46
|
+
powershell = _wsl_powershell()
|
|
47
|
+
if powershell is None:
|
|
48
|
+
return ClipboardUnavailable("Image clipboard paste is currently supported on WSL; use /image <path>")
|
|
49
|
+
script = r"""
|
|
50
|
+
$ErrorActionPreference = 'Stop'
|
|
51
|
+
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
|
52
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
53
|
+
if ([Windows.Forms.Clipboard]::ContainsImage()) {
|
|
54
|
+
@{kind='image'} | ConvertTo-Json -Compress
|
|
55
|
+
} elseif ([Windows.Forms.Clipboard]::ContainsFileDropList()) {
|
|
56
|
+
$items = @([Windows.Forms.Clipboard]::GetFileDropList() | ForEach-Object { [string]$_ })
|
|
57
|
+
@{kind='files'; paths=$items} | ConvertTo-Json -Compress
|
|
58
|
+
} elseif ([Windows.Forms.Clipboard]::ContainsText()) {
|
|
59
|
+
@{kind='text'; text=[Windows.Forms.Clipboard]::GetText()} | ConvertTo-Json -Compress
|
|
60
|
+
} else {
|
|
61
|
+
@{kind='unavailable'; reason='Clipboard has no text, image, or files'} | ConvertTo-Json -Compress
|
|
62
|
+
}
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
raw = _run_capture([powershell, "-NoProfile", "-STA", "-Command", script], timeout=5)
|
|
66
|
+
payload = json.loads(raw)
|
|
67
|
+
except (ClipboardError, json.JSONDecodeError) as exc:
|
|
68
|
+
return ClipboardUnavailable(str(exc))
|
|
69
|
+
kind = payload.get("kind")
|
|
70
|
+
if kind == "image":
|
|
71
|
+
return ClipboardImage()
|
|
72
|
+
if kind == "files":
|
|
73
|
+
paths = payload.get("paths") or []
|
|
74
|
+
if isinstance(paths, str):
|
|
75
|
+
paths = [paths]
|
|
76
|
+
return ClipboardFiles(tuple(str(path) for path in paths))
|
|
77
|
+
if kind == "text":
|
|
78
|
+
return ClipboardText(str(payload.get("text") or ""))
|
|
79
|
+
return ClipboardUnavailable(str(payload.get("reason") or "Clipboard content is unavailable"))
|
|
80
|
+
|
|
81
|
+
def export_image(self) -> Path:
|
|
82
|
+
powershell = _wsl_powershell()
|
|
83
|
+
if powershell is None:
|
|
84
|
+
raise ClipboardError("WSL PowerShell clipboard is unavailable")
|
|
85
|
+
fd, name = tempfile.mkstemp(prefix="deep-agent-clipboard-", suffix=".png")
|
|
86
|
+
os.close(fd)
|
|
87
|
+
path = Path(name)
|
|
88
|
+
path.unlink(missing_ok=True)
|
|
89
|
+
windows_path = _wslpath(path, to_windows=True)
|
|
90
|
+
escaped = windows_path.replace("'", "''")
|
|
91
|
+
script = rf"""
|
|
92
|
+
$ErrorActionPreference = 'Stop'
|
|
93
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
94
|
+
Add-Type -AssemblyName System.Drawing
|
|
95
|
+
$image = [Windows.Forms.Clipboard]::GetImage()
|
|
96
|
+
if ($null -eq $image) {{ throw 'Clipboard image is no longer available' }}
|
|
97
|
+
try {{ $image.Save('{escaped}', [Drawing.Imaging.ImageFormat]::Png) }} finally {{ $image.Dispose() }}
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
_run_capture([powershell, "-NoProfile", "-STA", "-Command", script], timeout=10)
|
|
101
|
+
if not path.is_file():
|
|
102
|
+
raise ClipboardError("PowerShell did not export the clipboard image")
|
|
103
|
+
return path
|
|
104
|
+
except Exception:
|
|
105
|
+
path.unlink(missing_ok=True)
|
|
106
|
+
raise
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def copy_to_clipboard(text: str, *, output: Any | None = None) -> str:
|
|
110
|
+
"""Copy text and return the backend name used.
|
|
111
|
+
|
|
112
|
+
WSL's ``clip.exe`` expects UTF-16LE on stdin. Other native helpers accept
|
|
113
|
+
UTF-8. OSC 52 is the final fallback for terminals without a local helper.
|
|
114
|
+
"""
|
|
115
|
+
if not text:
|
|
116
|
+
raise ClipboardError("Nothing selected")
|
|
117
|
+
|
|
118
|
+
clip = _wsl_clipboard()
|
|
119
|
+
if clip is not None:
|
|
120
|
+
_run([clip], text.encode("utf-16le"))
|
|
121
|
+
return "clip.exe"
|
|
122
|
+
|
|
123
|
+
candidates = (
|
|
124
|
+
(["pbcopy"], "pbcopy"),
|
|
125
|
+
(["wl-copy"], "wl-copy"),
|
|
126
|
+
(["xclip", "-selection", "clipboard"], "xclip"),
|
|
127
|
+
(["xsel", "--clipboard", "--input"], "xsel"),
|
|
128
|
+
)
|
|
129
|
+
for command, name in candidates:
|
|
130
|
+
executable = shutil.which(command[0])
|
|
131
|
+
if executable is None:
|
|
132
|
+
continue
|
|
133
|
+
_run([executable, *command[1:]], text.encode("utf-8"))
|
|
134
|
+
return name
|
|
135
|
+
|
|
136
|
+
if output is not None and hasattr(output, "write_raw"):
|
|
137
|
+
try:
|
|
138
|
+
encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
|
139
|
+
output.write_raw(f"\x1b]52;c;{encoded}\x07")
|
|
140
|
+
output.flush()
|
|
141
|
+
return "OSC 52"
|
|
142
|
+
except (OSError, ValueError) as exc:
|
|
143
|
+
raise ClipboardError(f"OSC 52 clipboard failed: {exc}") from exc
|
|
144
|
+
|
|
145
|
+
raise ClipboardError("No system clipboard helper is available")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _wsl_clipboard() -> str | None:
|
|
149
|
+
if not _is_wsl():
|
|
150
|
+
return None
|
|
151
|
+
executable = shutil.which("clip.exe")
|
|
152
|
+
if executable:
|
|
153
|
+
return executable
|
|
154
|
+
fallback = Path("/mnt/c/Windows/System32/clip.exe")
|
|
155
|
+
return str(fallback) if fallback.is_file() else None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _wsl_powershell() -> str | None:
|
|
159
|
+
if not _is_wsl():
|
|
160
|
+
return None
|
|
161
|
+
executable = shutil.which("powershell.exe")
|
|
162
|
+
if executable:
|
|
163
|
+
return executable
|
|
164
|
+
fallback = Path("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe")
|
|
165
|
+
return str(fallback) if fallback.is_file() else None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def windows_path_to_wsl(value: str) -> Path:
|
|
169
|
+
if not _is_wsl() or not re_windows_path(value):
|
|
170
|
+
return Path(value).expanduser()
|
|
171
|
+
return Path(_wslpath(Path(value), to_windows=False))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def re_windows_path(value: str) -> bool:
|
|
175
|
+
return len(value) >= 3 and value[1] == ":" and value[2] in {"\\", "/"}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _wslpath(path: Path, *, to_windows: bool) -> str:
|
|
179
|
+
command = ["wslpath", "-w" if to_windows else "-u", str(path)]
|
|
180
|
+
return _run_capture(command, timeout=3).strip()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _is_wsl() -> bool:
|
|
184
|
+
if os.environ.get("WSL_DISTRO_NAME") or os.environ.get("WSL_INTEROP"):
|
|
185
|
+
return True
|
|
186
|
+
try:
|
|
187
|
+
return "microsoft" in Path("/proc/sys/kernel/osrelease").read_text(encoding="utf-8").lower()
|
|
188
|
+
except OSError:
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _run(command: list[str], payload: bytes) -> None:
|
|
193
|
+
try:
|
|
194
|
+
subprocess.run(
|
|
195
|
+
command,
|
|
196
|
+
input=payload,
|
|
197
|
+
stdout=subprocess.DEVNULL,
|
|
198
|
+
stderr=subprocess.PIPE,
|
|
199
|
+
check=True,
|
|
200
|
+
timeout=2,
|
|
201
|
+
)
|
|
202
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
203
|
+
detail = getattr(exc, "stderr", b"")
|
|
204
|
+
if isinstance(detail, bytes):
|
|
205
|
+
detail = detail.decode("utf-8", errors="replace").strip()
|
|
206
|
+
suffix = f": {detail}" if detail else ""
|
|
207
|
+
raise ClipboardError(f"Clipboard command failed{suffix}") from exc
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _run_capture(command: list[str], *, timeout: int) -> str:
|
|
211
|
+
try:
|
|
212
|
+
result = subprocess.run(
|
|
213
|
+
command,
|
|
214
|
+
stdout=subprocess.PIPE,
|
|
215
|
+
stderr=subprocess.PIPE,
|
|
216
|
+
check=True,
|
|
217
|
+
timeout=timeout,
|
|
218
|
+
)
|
|
219
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
220
|
+
detail = getattr(exc, "stderr", b"")
|
|
221
|
+
if isinstance(detail, bytes):
|
|
222
|
+
detail = detail.decode("utf-8", errors="replace").strip()
|
|
223
|
+
raise ClipboardError(f"Clipboard command failed{f': {detail}' if detail else ''}") from exc
|
|
224
|
+
return result.stdout.decode("utf-8", errors="replace").lstrip("\ufeff").strip()
|
agent/cli/commands.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING, Awaitable, Callable
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from agent.cli.app import CliApplication
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
CommandHandler = Callable[["CliApplication", str], Awaitable[None]]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Command:
|
|
15
|
+
name: str
|
|
16
|
+
description: str
|
|
17
|
+
handler: CommandHandler
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def command_table() -> tuple[Command, ...]:
|
|
21
|
+
return (
|
|
22
|
+
Command("help", "Show commands and keyboard shortcuts", _help),
|
|
23
|
+
Command("clear", "Clear the visible transcript", _clear),
|
|
24
|
+
Command("status", "Show runtime and sandbox status", _status),
|
|
25
|
+
Command("pause", "Pause at the next model safe point", _pause),
|
|
26
|
+
Command("new", "Start a new persistent session thread", _new),
|
|
27
|
+
Command("session", "Show current session information", _session),
|
|
28
|
+
Command("resume", "List sessions with content (/resume or /resume <id-prefix>)", _resume),
|
|
29
|
+
Command("model", "Select a model (/model or /model <id-prefix>)", _model),
|
|
30
|
+
Command("compact", "Summarize older conversation when context permits", _compact),
|
|
31
|
+
Command("image", "Attach an image (/image <path|clipboard>, /image, /image clear)", _image),
|
|
32
|
+
Command("attachments", "Attachment maintenance (/attachments cleanup)", _attachments),
|
|
33
|
+
Command(
|
|
34
|
+
"permission",
|
|
35
|
+
"Tool approval: ask (every execute) or allow (SANDBOXED auto-approve + sandbox network open, HIGH RISK)",
|
|
36
|
+
_permission,
|
|
37
|
+
),
|
|
38
|
+
Command("quit", "Exit DeepAgent", _quit),
|
|
39
|
+
Command("exit", "Exit DeepAgent", _quit),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _help(app: "CliApplication", _arg: str) -> None:
|
|
44
|
+
app.show_help()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _clear(app: "CliApplication", _arg: str) -> None:
|
|
48
|
+
app.state.clear()
|
|
49
|
+
app.set_status("Transcript cleared; agent context was preserved")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _status(app: "CliApplication", _arg: str) -> None:
|
|
53
|
+
app.show_status()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _pause(app: "CliApplication", _arg: str) -> None:
|
|
57
|
+
app.runner.request_pause()
|
|
58
|
+
app.set_status("Pause requested for the next model safe point")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _new(app: "CliApplication", _arg: str) -> None:
|
|
62
|
+
app.new_session()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def _session(app: "CliApplication", _arg: str) -> None:
|
|
66
|
+
app.show_session()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _resume(app: "CliApplication", arg: str) -> None:
|
|
70
|
+
await app.resume_session(arg)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def _model(app: "CliApplication", arg: str) -> None:
|
|
74
|
+
await app.select_model(arg)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def _compact(app: "CliApplication", arg: str) -> None:
|
|
78
|
+
await app.compact_command(arg)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def _image(app: "CliApplication", arg: str) -> None:
|
|
82
|
+
await app.image_command(arg)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _attachments(app: "CliApplication", arg: str) -> None:
|
|
86
|
+
await app.attachments_command(arg)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def _permission(app: "CliApplication", arg: str) -> None:
|
|
90
|
+
await app.select_permission(arg)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _quit(app: "CliApplication", _arg: str) -> None:
|
|
94
|
+
app.exit()
|
agent/cli/gitinfo.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Workspace git summary for the status footer.
|
|
2
|
+
|
|
3
|
+
The footer repaints several times a second, so git is probed on a timer in the
|
|
4
|
+
background and the UI only ever reads the last known summary.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
REFRESH_SECONDS = 5.0
|
|
14
|
+
TIMEOUT_SECONDS = 2.0
|
|
15
|
+
|
|
16
|
+
_NO_BRANCH_PREFIX = "No commits yet on "
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class GitSummary:
|
|
21
|
+
"""Branch name plus changed-path count; empty when this is not a repository."""
|
|
22
|
+
|
|
23
|
+
branch: str = ""
|
|
24
|
+
changed: int = 0
|
|
25
|
+
available: bool = False
|
|
26
|
+
|
|
27
|
+
def label(self) -> str:
|
|
28
|
+
if not self.available:
|
|
29
|
+
return ""
|
|
30
|
+
state = f"{self.changed} changed" if self.changed else "clean"
|
|
31
|
+
return f"⎇ {self.branch} · {state}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_status(output: str) -> GitSummary:
|
|
35
|
+
"""Parse `git status --porcelain --branch` output."""
|
|
36
|
+
lines = output.splitlines()
|
|
37
|
+
branch = ""
|
|
38
|
+
if lines and lines[0].startswith("## "):
|
|
39
|
+
head = lines[0][3:].strip()
|
|
40
|
+
lines = lines[1:]
|
|
41
|
+
if head.startswith(_NO_BRANCH_PREFIX):
|
|
42
|
+
branch = head.removeprefix(_NO_BRANCH_PREFIX).strip()
|
|
43
|
+
elif "no branch" in head or head.startswith("HEAD"):
|
|
44
|
+
branch = "detached"
|
|
45
|
+
else:
|
|
46
|
+
branch = head.split("...", 1)[0].strip()
|
|
47
|
+
return GitSummary(
|
|
48
|
+
branch=branch or "unknown",
|
|
49
|
+
changed=sum(1 for line in lines if line.strip()),
|
|
50
|
+
available=True,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class GitProbe:
|
|
55
|
+
"""Background `git status` whose last result is safe to read from a render pass."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, workspace: Path) -> None:
|
|
58
|
+
self.workspace = workspace
|
|
59
|
+
self.summary = GitSummary()
|
|
60
|
+
|
|
61
|
+
async def refresh(self) -> GitSummary:
|
|
62
|
+
process: asyncio.subprocess.Process | None = None
|
|
63
|
+
try:
|
|
64
|
+
process = await asyncio.create_subprocess_exec(
|
|
65
|
+
"git", "status", "--porcelain", "--branch",
|
|
66
|
+
cwd=str(self.workspace),
|
|
67
|
+
stdout=asyncio.subprocess.PIPE,
|
|
68
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
69
|
+
)
|
|
70
|
+
stdout, _ = await asyncio.wait_for(process.communicate(), timeout=TIMEOUT_SECONDS)
|
|
71
|
+
self.summary = (
|
|
72
|
+
parse_status(stdout.decode("utf-8", "replace"))
|
|
73
|
+
if process.returncode == 0 else GitSummary()
|
|
74
|
+
)
|
|
75
|
+
except (OSError, ValueError, TimeoutError, subprocess.SubprocessError):
|
|
76
|
+
self.summary = GitSummary()
|
|
77
|
+
finally:
|
|
78
|
+
if process is not None and process.returncode is None:
|
|
79
|
+
try:
|
|
80
|
+
process.kill()
|
|
81
|
+
except ProcessLookupError:
|
|
82
|
+
pass
|
|
83
|
+
await process.wait()
|
|
84
|
+
return self.summary
|
agent/cli/input.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
DEFAULT_BINDINGS: dict[str, list[str]] = {
|
|
9
|
+
"submit": ["enter"],
|
|
10
|
+
"newline": ["c-j"],
|
|
11
|
+
"follow_up": ["alt+enter"],
|
|
12
|
+
"interrupt": ["escape"],
|
|
13
|
+
"clear_or_exit": ["c-c"],
|
|
14
|
+
"exit": ["c-d"],
|
|
15
|
+
"tools_expand": ["c-o"],
|
|
16
|
+
"review_diff": ["c-r"],
|
|
17
|
+
"reopen_interaction": ["f2"],
|
|
18
|
+
"thinking_toggle": ["c-t"],
|
|
19
|
+
"dequeue": ["alt+up"],
|
|
20
|
+
# Windows Terminal may reserve Ctrl+V for terminal paste. Alt+V is the
|
|
21
|
+
# reliable application-level image clipboard fallback.
|
|
22
|
+
"image_paste": ["ctrl+v", "alt+v"],
|
|
23
|
+
# prompt_toolkit has no ControlShift+letter; Alt+P is the backward cycle fallback.
|
|
24
|
+
"model_cycle_forward": ["ctrl+p"],
|
|
25
|
+
"model_cycle_backward": ["alt+p"],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Keymap:
|
|
31
|
+
bindings: dict[str, list[str]] = field(default_factory=lambda: {
|
|
32
|
+
name: list(keys) for name, keys in DEFAULT_BINDINGS.items()
|
|
33
|
+
})
|
|
34
|
+
warning: str = ""
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def load(cls, path: Path | None) -> "Keymap":
|
|
38
|
+
result = cls()
|
|
39
|
+
if path is None or not path.is_file():
|
|
40
|
+
return result
|
|
41
|
+
try:
|
|
42
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
43
|
+
for action, value in raw.items():
|
|
44
|
+
if action not in result.bindings:
|
|
45
|
+
continue
|
|
46
|
+
keys = [value] if isinstance(value, str) else value
|
|
47
|
+
if isinstance(keys, list) and keys and all(isinstance(item, str) for item in keys):
|
|
48
|
+
result.bindings[action] = keys
|
|
49
|
+
except (OSError, ValueError, TypeError) as exc:
|
|
50
|
+
result.warning = f"Could not load keybindings: {exc}"
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
def sequences(self, action: str) -> list[tuple[str, ...]]:
|
|
54
|
+
return [_prompt_toolkit_keys(value) for value in self.bindings[action]]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _prompt_toolkit_keys(value: str) -> tuple[str, ...]:
|
|
58
|
+
keys = value.strip().lower()
|
|
59
|
+
if keys.startswith("alt+"):
|
|
60
|
+
return ("escape", keys.removeprefix("alt+"))
|
|
61
|
+
if keys.startswith("ctrl+"):
|
|
62
|
+
return (f"c-{keys.removeprefix('ctrl+')}",)
|
|
63
|
+
if keys == "shift+tab":
|
|
64
|
+
return ("s-tab",)
|
|
65
|
+
return tuple(part for part in keys.split() if part)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from rich.console import Group
|
|
7
|
+
from rich.markdown import Markdown
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class InteractionController:
|
|
14
|
+
kind: str
|
|
15
|
+
title: str
|
|
16
|
+
question: str
|
|
17
|
+
fields: list[dict[str, Any]]
|
|
18
|
+
interaction_id: str = ""
|
|
19
|
+
reason: str = ""
|
|
20
|
+
recommendation: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
impact: list[str] = field(default_factory=list)
|
|
22
|
+
index: int = 0
|
|
23
|
+
option_index: int = 0
|
|
24
|
+
values: dict[str, Any] = field(default_factory=dict)
|
|
25
|
+
error: str = ""
|
|
26
|
+
tool_call_ids: list[str] = field(default_factory=list)
|
|
27
|
+
calls: list[dict[str, Any]] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def human(cls, payload: dict[str, Any]) -> "InteractionController":
|
|
31
|
+
fields = payload.get("fields") if isinstance(payload.get("fields"), list) else []
|
|
32
|
+
if not fields:
|
|
33
|
+
fields = [{"id": "text", "type": "textarea", "label": "Reply", "required": True, "options": []}]
|
|
34
|
+
fields = [dict(field) for field in fields]
|
|
35
|
+
for field in fields:
|
|
36
|
+
if field.get("type") == "boolean" and not field.get("options"):
|
|
37
|
+
field["options"] = [
|
|
38
|
+
{"value": False, "label": "No", "description": ""},
|
|
39
|
+
{"value": True, "label": "Yes", "description": ""},
|
|
40
|
+
]
|
|
41
|
+
return cls(
|
|
42
|
+
kind="human",
|
|
43
|
+
title=str(payload.get("title") or "Input required"),
|
|
44
|
+
question=str(payload.get("question") or "Please provide the requested information."),
|
|
45
|
+
reason=str(payload.get("reason") or ""),
|
|
46
|
+
fields=fields,
|
|
47
|
+
interaction_id=str(payload.get("interactionId") or ""),
|
|
48
|
+
recommendation=payload.get("recommendation") if isinstance(payload.get("recommendation"), dict) else {},
|
|
49
|
+
impact=[str(item) for item in payload.get("impact", [])],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def approval(cls, calls: list[dict[str, Any]]) -> "InteractionController":
|
|
54
|
+
from agent.network import network_requested
|
|
55
|
+
|
|
56
|
+
declared: list[str] = []
|
|
57
|
+
for call in calls:
|
|
58
|
+
name = str(call.get("name") or "tool")
|
|
59
|
+
args = call.get("args") if isinstance(call.get("args"), dict) else {}
|
|
60
|
+
if name == "execute" and network_requested(args):
|
|
61
|
+
declared.append("HOST NETWORK (internet, localhost, LAN)")
|
|
62
|
+
elif name in {"write_file", "edit_file", "delete"}:
|
|
63
|
+
declared.append(name.upper())
|
|
64
|
+
caps = ", ".join(dict.fromkeys(declared)) if declared else "declared capabilities"
|
|
65
|
+
return cls(
|
|
66
|
+
kind="approval",
|
|
67
|
+
title="Approve tool call?",
|
|
68
|
+
question=f"Approve runs this call with its declared capabilities ({caps}).",
|
|
69
|
+
fields=[{
|
|
70
|
+
"id": "approved", "type": "single_select", "label": "Decision", "required": True,
|
|
71
|
+
"options": [
|
|
72
|
+
{"value": "reject", "label": "Reject", "description": "Deny this tool call"},
|
|
73
|
+
{"value": "approve", "label": "Run", "description": "Approve and execute"},
|
|
74
|
+
],
|
|
75
|
+
}],
|
|
76
|
+
tool_call_ids=[str(call.get("toolCallId") or "") for call in calls if call.get("toolCallId")],
|
|
77
|
+
calls=list(calls),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def pause(cls) -> "InteractionController":
|
|
82
|
+
return cls(
|
|
83
|
+
kind="pause",
|
|
84
|
+
title="Paused",
|
|
85
|
+
question="Continue the agent run?",
|
|
86
|
+
fields=[{
|
|
87
|
+
"id": "continue", "type": "single_select", "label": "Decision", "required": True,
|
|
88
|
+
"options": [
|
|
89
|
+
{"value": "stay", "label": "No", "description": ""},
|
|
90
|
+
{"value": "continue", "label": "Yes", "description": ""},
|
|
91
|
+
],
|
|
92
|
+
}],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def current(self) -> dict[str, Any]:
|
|
97
|
+
return self.fields[min(self.index, len(self.fields) - 1)]
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def accepts_text(self) -> bool:
|
|
101
|
+
return str(self.current.get("type")) in {"text", "textarea"}
|
|
102
|
+
|
|
103
|
+
def move(self, delta: int) -> None:
|
|
104
|
+
options = self.current.get("options") or []
|
|
105
|
+
if options:
|
|
106
|
+
self.option_index = (self.option_index + delta) % len(options)
|
|
107
|
+
|
|
108
|
+
def toggle(self) -> None:
|
|
109
|
+
if self.current.get("type") != "multi_select":
|
|
110
|
+
return
|
|
111
|
+
options = self.current.get("options") or []
|
|
112
|
+
if not options:
|
|
113
|
+
return
|
|
114
|
+
field_id = str(self.current.get("id") or "field")
|
|
115
|
+
selected = list(self.values.get(field_id) or [])
|
|
116
|
+
value = str(options[self.option_index].get("value"))
|
|
117
|
+
selected.remove(value) if value in selected else selected.append(value)
|
|
118
|
+
self.values[field_id] = selected
|
|
119
|
+
|
|
120
|
+
def accept(self, text: str = "") -> bool:
|
|
121
|
+
field = self.current
|
|
122
|
+
field_id = str(field.get("id") or f"field_{self.index}")
|
|
123
|
+
field_type = str(field.get("type") or "text")
|
|
124
|
+
options = field.get("options") or []
|
|
125
|
+
if field_type in {"text", "textarea"}:
|
|
126
|
+
value: Any = text.strip()
|
|
127
|
+
elif field_type == "multi_select":
|
|
128
|
+
value = self.values.get(field_id) or []
|
|
129
|
+
elif options:
|
|
130
|
+
raw_value = options[self.option_index].get("value")
|
|
131
|
+
value = raw_value if field_type == "boolean" else str(raw_value or "")
|
|
132
|
+
else:
|
|
133
|
+
value = text.strip()
|
|
134
|
+
if field.get("required") and (value == "" or value == []):
|
|
135
|
+
self.error = "This field is required."
|
|
136
|
+
return False
|
|
137
|
+
self.values[field_id] = value
|
|
138
|
+
self.error = ""
|
|
139
|
+
if self.index + 1 < len(self.fields):
|
|
140
|
+
self.index += 1
|
|
141
|
+
self.option_index = 0
|
|
142
|
+
return False
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
def render(self) -> Group:
|
|
146
|
+
parts: list[Any] = [Text(self.title, style="bold bright_cyan")]
|
|
147
|
+
if self.reason:
|
|
148
|
+
parts.append(Text(self.reason, style="dim"))
|
|
149
|
+
if self.question:
|
|
150
|
+
parts.append(Text(self.question) if self.kind == "approval" else Markdown(self.question))
|
|
151
|
+
if self.recommendation:
|
|
152
|
+
value = self.recommendation.get("value") or ""
|
|
153
|
+
why = self.recommendation.get("reason") or ""
|
|
154
|
+
parts.append(Text(f"Recommended: {value} {why}".strip(), style="cyan"))
|
|
155
|
+
if self.impact:
|
|
156
|
+
parts.append(Text("Impact:\n" + "\n".join(f"• {item}" for item in self.impact), style="yellow"))
|
|
157
|
+
field = self.current
|
|
158
|
+
parts.append(Text(f"{self.index + 1}/{len(self.fields)} {field.get('label') or field.get('id')}", style="bold"))
|
|
159
|
+
options = field.get("options") or []
|
|
160
|
+
selected = self.values.get(str(field.get("id") or "")) or []
|
|
161
|
+
for idx, option in enumerate(options):
|
|
162
|
+
pointer = "→" if idx == self.option_index else " "
|
|
163
|
+
mark = "[x]" if option.get("value") in selected else "[ ]" if field.get("type") == "multi_select" else ""
|
|
164
|
+
description = option.get("description") or ""
|
|
165
|
+
style = "bold cyan" if idx == self.option_index else ""
|
|
166
|
+
label = f"{pointer} {mark} {option.get('label')} {description}".rstrip()
|
|
167
|
+
if right_label := option.get("right_label"):
|
|
168
|
+
row = Table.grid(expand=True, padding=(0, 1))
|
|
169
|
+
row.add_column(ratio=1, no_wrap=True, overflow="ellipsis")
|
|
170
|
+
row.add_column(width=len(str(right_label)), justify="right", no_wrap=True)
|
|
171
|
+
row.add_row(Text(label, style=style), Text(str(right_label), style=style))
|
|
172
|
+
parts.append(row)
|
|
173
|
+
else:
|
|
174
|
+
parts.append(Text(label, style=style))
|
|
175
|
+
if self.error:
|
|
176
|
+
parts.append(Text(self.error, style="red"))
|
|
177
|
+
hint = (
|
|
178
|
+
"↑↓ select Enter confirm Esc back"
|
|
179
|
+
if self.kind == "model" and self.values.get("source")
|
|
180
|
+
else "↑↓ select Enter confirm Esc cancel · F2 to reopen"
|
|
181
|
+
if self.kind in {"approval", "human", "pause"} and options
|
|
182
|
+
else "↑↓ select Enter confirm Esc cancel"
|
|
183
|
+
if options
|
|
184
|
+
else "Enter submit Ctrl+J newline Esc cancel"
|
|
185
|
+
)
|
|
186
|
+
parts.append(Text(hint, style="dim"))
|
|
187
|
+
return Group(*parts)
|