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.
Files changed (50) hide show
  1. agent/__init__.py +42 -0
  2. agent/attachments.py +303 -0
  3. agent/bootstrap.py +44 -0
  4. agent/cancel.py +107 -0
  5. agent/cli/__init__.py +5 -0
  6. agent/cli/app.py +1768 -0
  7. agent/cli/clipboard.py +224 -0
  8. agent/cli/commands.py +94 -0
  9. agent/cli/gitinfo.py +84 -0
  10. agent/cli/input.py +65 -0
  11. agent/cli/interactions.py +187 -0
  12. agent/cli/main.py +124 -0
  13. agent/cli/previews.py +710 -0
  14. agent/cli/rendering.py +770 -0
  15. agent/cli/session_controller.py +221 -0
  16. agent/cli/state.py +326 -0
  17. agent/config.example.yaml +76 -0
  18. agent/config.py +528 -0
  19. agent/control.py +171 -0
  20. agent/factory.py +232 -0
  21. agent/file_mutation.py +5 -0
  22. agent/llm.py +339 -0
  23. agent/middleware/__init__.py +9 -0
  24. agent/middleware/attachments.py +31 -0
  25. agent/middleware/cancel_tools.py +39 -0
  26. agent/middleware/pause.py +18 -0
  27. agent/middleware/recovery.py +65 -0
  28. agent/middleware/steering.py +35 -0
  29. agent/middleware/tool_arg_hints.py +128 -0
  30. agent/middleware/workspace_filesystem.py +38 -0
  31. agent/middleware/write_operation.py +60 -0
  32. agent/network.py +30 -0
  33. agent/permission.py +80 -0
  34. agent/runner.py +1393 -0
  35. agent/sandbox.py +699 -0
  36. agent/session.py +431 -0
  37. agent/session_lock.py +223 -0
  38. agent/session_runtime.py +209 -0
  39. agent/stream.py +168 -0
  40. agent/tools/__init__.py +9 -0
  41. agent/tools/examples.py +30 -0
  42. agent/tools/execute.py +73 -0
  43. agent/tools/human_input.py +170 -0
  44. agent/tools/human_interaction.py +101 -0
  45. agent/tools/web_search.py +131 -0
  46. deep_agent_cli-0.1.0.dist-info/METADATA +408 -0
  47. deep_agent_cli-0.1.0.dist-info/RECORD +50 -0
  48. deep_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  49. deep_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  50. 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)