zjcode 0.0.1__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 (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,602 @@
1
+ """Approval widget for HITL - using standard Textual patterns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING, Any, ClassVar
7
+
8
+ from textual.binding import Binding, BindingType
9
+ from textual.containers import Container, Vertical, VerticalScroll
10
+ from textual.content import Content
11
+ from textual.message import Message
12
+ from textual.widgets import Input, Static
13
+
14
+ if TYPE_CHECKING:
15
+ import asyncio
16
+
17
+ from textual import events
18
+ from textual.app import ComposeResult
19
+
20
+ from deepagents_code import theme
21
+ from deepagents_code.config import (
22
+ get_glyphs,
23
+ is_ascii_mode,
24
+ )
25
+ from deepagents_code.tui.widgets.tool_renderers import get_renderer
26
+ from deepagents_code.unicode_security import (
27
+ check_url_safety,
28
+ detect_dangerous_unicode,
29
+ format_warning_detail,
30
+ iter_string_values,
31
+ looks_like_url_key,
32
+ render_with_unicode_markers,
33
+ strip_dangerous_unicode,
34
+ summarize_issues,
35
+ )
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # Max length for truncated shell command display
40
+ _SHELL_COMMAND_TRUNCATE_LENGTH: int = 120
41
+ # Max number of lines for truncated shell command display
42
+ _SHELL_COMMAND_TRUNCATE_LINES: int = 5
43
+ _WARNING_PREVIEW_LIMIT: int = 3
44
+ _WARNING_TEXT_TRUNCATE_LENGTH: int = 220
45
+ # Must match the "reject" entry in `_handle_selection`'s decision map.
46
+ _REJECT_OPTION_INDEX: int = 2
47
+
48
+
49
+ def _is_command_too_long(command: str) -> bool:
50
+ """Whether a shell command exceeds the display thresholds (char or line).
51
+
52
+ Args:
53
+ command: The shell command string to check.
54
+
55
+ Returns:
56
+ `True` if the command is longer than `_SHELL_COMMAND_TRUNCATE_LENGTH`
57
+ characters or has more than `_SHELL_COMMAND_TRUNCATE_LINES` lines.
58
+ """
59
+ if len(command) > _SHELL_COMMAND_TRUNCATE_LENGTH:
60
+ return True
61
+ return command.count("\n") + 1 > _SHELL_COMMAND_TRUNCATE_LINES
62
+
63
+
64
+ def _truncate_command(command: str) -> str:
65
+ """Truncate a shell command for compact display.
66
+
67
+ Applies line truncation first (keeping at most `_SHELL_COMMAND_TRUNCATE_LINES`
68
+ lines), then character truncation, so multi-line commands collapse before
69
+ long single lines are cut. A single ellipsis is appended at the end.
70
+
71
+ Args:
72
+ command: The shell command string to truncate.
73
+
74
+ Returns:
75
+ The truncated command string, with a trailing ellipsis if any truncation
76
+ was applied; otherwise the original command unchanged.
77
+ """
78
+ ellipsis = get_glyphs().ellipsis
79
+ lines = command.split("\n")
80
+ truncated = len(lines) > _SHELL_COMMAND_TRUNCATE_LINES
81
+ if truncated:
82
+ command = "\n".join(lines[:_SHELL_COMMAND_TRUNCATE_LINES])
83
+ if len(command) > _SHELL_COMMAND_TRUNCATE_LENGTH:
84
+ command = command[:_SHELL_COMMAND_TRUNCATE_LENGTH]
85
+ truncated = True
86
+ return command + ellipsis if truncated else command
87
+
88
+
89
+ class ApprovalMenu(Container):
90
+ """Approval menu using standard Textual patterns.
91
+
92
+ Key design decisions (following mistral-vibe reference):
93
+ - Container base class with compose()
94
+ - BINDINGS for key handling (not on_key)
95
+ - can_focus_children = False to prevent focus theft
96
+ - Simple Static widgets for options
97
+ - Standard message posting
98
+ - Tool-specific widgets via renderer pattern
99
+ """
100
+
101
+ can_focus = True
102
+ can_focus_children = False
103
+
104
+ # CSS is in app.tcss - no DEFAULT_CSS needed
105
+
106
+ BINDINGS: ClassVar[list[BindingType]] = [
107
+ Binding("up", "move_up", "Up", show=False),
108
+ Binding("k", "move_up", "Up", show=False),
109
+ Binding("down", "move_down", "Down", show=False),
110
+ Binding("j", "move_down", "Down", show=False),
111
+ Binding("enter", "select", "Select", show=False),
112
+ Binding("1", "select_approve", "Approve", show=False),
113
+ Binding("y", "select_approve", "Approve", show=False),
114
+ Binding("2", "select_auto", "Auto-approve", show=False),
115
+ Binding("a", "select_auto", "Auto-approve", show=False),
116
+ Binding("3", "select_reject", "Reject", show=False),
117
+ Binding("n", "select_reject", "Reject", show=False),
118
+ Binding("e", "toggle_expand", "Expand command", show=False),
119
+ Binding("tab", "reject_with_reason", "Reject with reason", show=False),
120
+ ]
121
+
122
+ class Decided(Message):
123
+ """Message sent when user makes a decision."""
124
+
125
+ def __init__(self, decision: dict[str, str]) -> None:
126
+ """Initialize a Decided message with the user's decision.
127
+
128
+ Args:
129
+ decision: Dictionary containing the decision type (e.g., 'approve',
130
+ 'reject', or 'auto_approve_all').
131
+ """
132
+ super().__init__()
133
+ self.decision = decision
134
+
135
+ # Tools that don't need detailed info display (already shown in tool call)
136
+ _MINIMAL_TOOLS: ClassVar[frozenset[str]] = frozenset({"execute"})
137
+
138
+ def __init__(
139
+ self,
140
+ action_requests: list[dict[str, Any]] | dict[str, Any],
141
+ assistant_id: str | None = None,
142
+ id: str | None = None, # noqa: A002 # Textual widget constructor uses `id` parameter
143
+ **kwargs: Any,
144
+ ) -> None:
145
+ """Initialize the ApprovalMenu widget.
146
+
147
+ Args:
148
+ action_requests: A single action request dictionary or a list of action
149
+ request dictionaries requiring approval. Each dictionary should
150
+ contain 'name' (tool name) and 'args' (tool arguments).
151
+ assistant_id: Optional assistant ID for resolving virtual paths in
152
+ file-operation previews.
153
+ id: Optional widget ID. Defaults to 'approval-menu'.
154
+ **kwargs: Additional keyword arguments passed to the Container base class.
155
+ """
156
+ super().__init__(id=id or "approval-menu", classes="approval-menu", **kwargs)
157
+ # Support both single request (legacy) and list of requests (batch)
158
+ if isinstance(action_requests, dict):
159
+ self._action_requests = [action_requests]
160
+ else:
161
+ self._action_requests = action_requests
162
+
163
+ self._assistant_id = assistant_id
164
+ # For display purposes, get tool names
165
+ self._tool_names = [r.get("name", "unknown") for r in self._action_requests]
166
+ self._selected = 0
167
+ self._future: asyncio.Future[dict[str, str]] | None = None
168
+ self._option_widgets: list[Static] = []
169
+ self._tool_info_container: Vertical | None = None
170
+ # Minimal display if ALL tools are shell-execution tools
171
+ self._is_minimal = all(name in self._MINIMAL_TOOLS for name in self._tool_names)
172
+ # For expandable shell commands
173
+ self._command_expanded = False
174
+ self._command_widget: Static | None = None
175
+ self._has_expandable_command = self._check_expandable_command()
176
+ self._security_warnings = self._collect_security_warnings()
177
+ # Free-text reject mode state (Tab on Reject opens an inline Input).
178
+ self._reason_input: Input | None = None
179
+ self._reason_input_active = False
180
+ self._help_widget: Static | None = None
181
+
182
+ def set_future(self, future: asyncio.Future[dict[str, str]]) -> None:
183
+ """Set the future to resolve when user decides."""
184
+ self._future = future
185
+
186
+ def _check_expandable_command(self) -> bool:
187
+ """Check if there's a shell command that can be expanded.
188
+
189
+ Returns:
190
+ Whether the single action request is an expandable shell command.
191
+ """
192
+ if len(self._action_requests) != 1:
193
+ return False
194
+ req = self._action_requests[0]
195
+ if req.get("name", "") != "execute":
196
+ return False
197
+ command = str(req.get("args", {}).get("command", ""))
198
+ return _is_command_too_long(command)
199
+
200
+ def _get_command_display(self, *, expanded: bool) -> Content:
201
+ """Get the command display content (truncated or full).
202
+
203
+ Args:
204
+ expanded: Whether to show the full command or truncated version.
205
+
206
+ Returns:
207
+ Styled Content for the command display.
208
+
209
+ Raises:
210
+ RuntimeError: If called with empty action_requests.
211
+ """
212
+ if not self._action_requests:
213
+ msg = "_get_command_display called with empty action_requests"
214
+ raise RuntimeError(msg)
215
+ req = self._action_requests[0]
216
+ command_raw = str(req.get("args", {}).get("command", ""))
217
+ command = strip_dangerous_unicode(command_raw)
218
+ issues = detect_dangerous_unicode(command_raw)
219
+
220
+ too_long = _is_command_too_long(command)
221
+ if expanded or not too_long:
222
+ command_display = command
223
+ else:
224
+ command_display = _truncate_command(command)
225
+
226
+ if not expanded and too_long:
227
+ display = Content.from_markup(
228
+ "[bold]$cmd[/bold] [dim](press 'e' to expand)[/dim]",
229
+ cmd=command_display,
230
+ )
231
+ else:
232
+ display = Content.from_markup("[bold]$cmd[/bold]", cmd=command_display)
233
+
234
+ if not issues:
235
+ return display
236
+
237
+ raw_with_markers = render_with_unicode_markers(command_raw)
238
+ if not expanded and len(raw_with_markers) > _WARNING_TEXT_TRUNCATE_LENGTH:
239
+ raw_with_markers = (
240
+ raw_with_markers[:_WARNING_TEXT_TRUNCATE_LENGTH] + get_glyphs().ellipsis
241
+ )
242
+
243
+ return Content.assemble(
244
+ display,
245
+ Content.from_markup(
246
+ "\n[yellow]Warning:[/yellow] hidden chars detected ($summary)\n"
247
+ "[dim]raw: $raw[/dim]",
248
+ summary=summarize_issues(issues),
249
+ raw=raw_with_markers,
250
+ ),
251
+ )
252
+
253
+ def compose(self) -> ComposeResult:
254
+ """Compose the widget with Static children.
255
+
256
+ Layout: Tool info first (what's being approved), then options at bottom.
257
+ For bash/shell, skip tool info since it's already shown in tool call.
258
+
259
+ Yields:
260
+ Widgets for title, tool info, options, and help text.
261
+ """
262
+ # Title - show count if multiple tools
263
+ count = len(self._action_requests)
264
+ if count == 1:
265
+ title = Content.from_markup(
266
+ ">>> $name Requires Approval <<<", name=self._tool_names[0]
267
+ )
268
+ else:
269
+ title = Content(f">>> {count} Tool Calls Require Approval <<<")
270
+ yield Static(title, classes="approval-title")
271
+
272
+ if self._security_warnings:
273
+ parts: list[Content] = [
274
+ Content.from_markup(
275
+ "[yellow]Warning:[/yellow] Potentially deceptive text"
276
+ ),
277
+ ]
278
+ parts.extend(
279
+ Content.from_markup("\n[dim]- $w[/dim]", w=warning)
280
+ for warning in self._security_warnings[:_WARNING_PREVIEW_LIMIT]
281
+ )
282
+ if len(self._security_warnings) > _WARNING_PREVIEW_LIMIT:
283
+ remaining = len(self._security_warnings) - _WARNING_PREVIEW_LIMIT
284
+ parts.append(Content.styled(f"\n- +{remaining} more warning(s)", "dim"))
285
+ yield Static(
286
+ Content.assemble(*parts),
287
+ classes="approval-security-warning",
288
+ )
289
+
290
+ # For shell commands, show the command (expandable if long)
291
+ if self._is_minimal and len(self._action_requests) == 1:
292
+ self._command_widget = Static(
293
+ self._get_command_display(expanded=self._command_expanded),
294
+ classes="approval-command",
295
+ )
296
+ yield self._command_widget
297
+
298
+ # Tool info - only for non-minimal tools (diffs, writes show actual content)
299
+ if not self._is_minimal:
300
+ with VerticalScroll(classes="tool-info-scroll"):
301
+ self._tool_info_container = Vertical(classes="tool-info-container")
302
+ yield self._tool_info_container
303
+
304
+ # Separator between tool details and options
305
+ glyphs = get_glyphs()
306
+ yield Static(glyphs.box_horizontal * 40, classes="approval-separator")
307
+
308
+ # Options container at bottom
309
+ with Container(classes="approval-options-container"):
310
+ # Options - create 3 Static widgets
311
+ for i in range(3): # noqa: B007 # Loop variable unused - iterating for count only
312
+ widget = Static("", classes="approval-option")
313
+ self._option_widgets.append(widget)
314
+ yield widget
315
+
316
+ # Free-text reject reason input (hidden until activated via Tab)
317
+ self._reason_input = Input(
318
+ placeholder="Reason (Enter to submit, Esc to cancel)",
319
+ classes="approval-reason-input",
320
+ id="approval-reason-input",
321
+ )
322
+ self._reason_input.display = False
323
+ yield self._reason_input
324
+
325
+ # Help text at the very bottom
326
+ self._help_widget = Static(self._compose_help_text(), classes="approval-help")
327
+ yield self._help_widget
328
+
329
+ def _compose_help_text(self) -> str:
330
+ """Build the help-line content for the current mode.
331
+
332
+ Returns:
333
+ Help text for either the normal menu or the reject-reason input.
334
+ """
335
+ glyphs = get_glyphs()
336
+ if self._reason_input_active:
337
+ return (
338
+ f"Enter submit {glyphs.bullet} Esc cancel {glyphs.bullet} "
339
+ "leave blank to reject without a reason"
340
+ )
341
+ help_parts = [
342
+ (
343
+ f"{glyphs.arrow_up}/{glyphs.arrow_down} navigate "
344
+ f"{glyphs.bullet} Enter select {glyphs.bullet} y/a/n quick keys"
345
+ ),
346
+ ]
347
+ if self._selected == _REJECT_OPTION_INDEX:
348
+ help_parts.append("Tab amend")
349
+ help_parts.append("Esc reject")
350
+ help_text = f" {glyphs.bullet} ".join(help_parts)
351
+ if self._has_expandable_command:
352
+ help_text += f" {glyphs.bullet} e expand"
353
+ return help_text
354
+
355
+ async def on_mount(self) -> None:
356
+ """Focus self on mount and update tool info."""
357
+ if is_ascii_mode():
358
+ colors = theme.get_theme_colors(self)
359
+ self.styles.border = ("ascii", colors.warning)
360
+
361
+ if not self._is_minimal:
362
+ await self._update_tool_info()
363
+ self._update_options()
364
+ self.focus()
365
+
366
+ async def _update_tool_info(self) -> None:
367
+ """Mount the tool-specific approval widgets for all tools."""
368
+ if not self._tool_info_container:
369
+ return
370
+
371
+ # Clear existing content
372
+ await self._tool_info_container.remove_children()
373
+
374
+ # Mount info for each tool
375
+ for i, action_request in enumerate(self._action_requests):
376
+ tool_name = action_request.get("name", "unknown")
377
+ tool_args = action_request.get("args", {})
378
+
379
+ # Add tool header if multiple tools
380
+ if len(self._action_requests) > 1:
381
+ header = Static(
382
+ Content.from_markup(
383
+ "[bold]$num. $name[/bold]",
384
+ num=i + 1,
385
+ name=tool_name,
386
+ )
387
+ )
388
+ await self._tool_info_container.mount(header)
389
+
390
+ # Show description if present
391
+ description = action_request.get("description")
392
+ if description:
393
+ desc_widget = Static(
394
+ Content.from_markup("[dim]$desc[/dim]", desc=description),
395
+ classes="approval-description",
396
+ )
397
+ await self._tool_info_container.mount(desc_widget)
398
+
399
+ # Get the appropriate renderer for this tool
400
+ renderer = get_renderer(tool_name)
401
+ widget_class, data = renderer.get_approval_widget(
402
+ tool_args, assistant_id=self._assistant_id
403
+ )
404
+ approval_widget = widget_class(data)
405
+ await self._tool_info_container.mount(approval_widget)
406
+
407
+ def _update_options(self) -> None:
408
+ """Update option widgets based on selection."""
409
+ count = len(self._action_requests)
410
+ if count == 1:
411
+ options = [
412
+ "1. Approve (y)",
413
+ "2. Auto-approve for this thread (a)",
414
+ "3. Reject (n)",
415
+ ]
416
+ else:
417
+ options = [
418
+ f"1. Approve all {count} (y)",
419
+ "2. Auto-approve for this thread (a)",
420
+ f"3. Reject all {count} (n)",
421
+ ]
422
+
423
+ for i, (text, widget) in enumerate(
424
+ zip(options, self._option_widgets, strict=True)
425
+ ):
426
+ cursor = f"{get_glyphs().cursor} " if i == self._selected else " "
427
+ widget.update(f"{cursor}{text}")
428
+
429
+ # Update classes
430
+ widget.remove_class("approval-option-selected")
431
+ if i == self._selected:
432
+ widget.add_class("approval-option-selected")
433
+ if self._help_widget is not None:
434
+ self._help_widget.update(self._compose_help_text())
435
+
436
+ def action_move_up(self) -> None:
437
+ """Move selection up."""
438
+ if self._reason_input_active:
439
+ return
440
+ self._selected = (self._selected - 1) % 3
441
+ self._update_options()
442
+
443
+ def action_move_down(self) -> None:
444
+ """Move selection down."""
445
+ if self._reason_input_active:
446
+ return
447
+ self._selected = (self._selected + 1) % 3
448
+ self._update_options()
449
+
450
+ def action_select(self) -> None:
451
+ """Select current option."""
452
+ self._handle_selection(self._selected)
453
+
454
+ def action_select_approve(self) -> None:
455
+ """Submit approve option."""
456
+ self._handle_selection(0)
457
+
458
+ def action_select_auto(self) -> None:
459
+ """Submit auto-approve option."""
460
+ self._handle_selection(1)
461
+
462
+ def action_select_reject(self) -> None:
463
+ """Submit reject option.
464
+
465
+ When the free-text reject input is open, the first press cancels the
466
+ input instead of rejecting, so the user can back out without losing
467
+ their unsubmitted reason.
468
+ """
469
+ if self._reason_input_active:
470
+ self._exit_reason_input_mode()
471
+ return
472
+ self._handle_selection(2)
473
+
474
+ def action_toggle_expand(self) -> None:
475
+ """Toggle shell command expansion."""
476
+ if not self._has_expandable_command or not self._command_widget:
477
+ return
478
+ self._command_expanded = not self._command_expanded
479
+ self._command_widget.update(
480
+ self._get_command_display(expanded=self._command_expanded)
481
+ )
482
+
483
+ def _handle_selection(
484
+ self, option: int, *, reject_message: str | None = None
485
+ ) -> None:
486
+ """Handle the selected option.
487
+
488
+ Args:
489
+ option: Index of the chosen option (0 approve, 1 auto-approve, 2 reject).
490
+ reject_message: Optional free-text reason. Only attached when the
491
+ user rejects with a non-empty message via `action_reject_with_reason`.
492
+ """
493
+ decision_map = {
494
+ 0: "approve",
495
+ 1: "auto_approve_all",
496
+ 2: "reject",
497
+ }
498
+ decision: dict[str, str] = {"type": decision_map[option]}
499
+ if option == _REJECT_OPTION_INDEX and reject_message:
500
+ decision["message"] = reject_message
501
+
502
+ self.display = False
503
+
504
+ # Resolve the future
505
+ if self._future and not self._future.done():
506
+ self._future.set_result(decision)
507
+
508
+ # Post message
509
+ self.post_message(self.Decided(decision))
510
+
511
+ def action_reject_with_reason(self) -> None:
512
+ """Enter free-text reject mode if Reject is currently selected.
513
+
514
+ No-op unless the cursor is on the Reject option. Mounts an inline
515
+ `Input` whose value is sent as `RejectDecision.message` on submit.
516
+ """
517
+ if self._reason_input_active:
518
+ return
519
+ if self._selected != _REJECT_OPTION_INDEX:
520
+ return
521
+ if self._reason_input is None:
522
+ # Lifecycle bug: Tab fired before `compose()` populated the Input ref.
523
+ # Logging makes the silent no-op debuggable instead of invisible.
524
+ logger.warning(
525
+ "action_reject_with_reason: _reason_input is None; menu may not "
526
+ "be mounted yet"
527
+ )
528
+ return
529
+ self._reason_input_active = True
530
+ self._reason_input.value = ""
531
+ self._reason_input.display = True
532
+ if self._help_widget is not None:
533
+ self._help_widget.update(self._compose_help_text())
534
+ self._reason_input.focus()
535
+
536
+ def _exit_reason_input_mode(self) -> None:
537
+ """Close the reason input and return focus to the menu without deciding."""
538
+ if not self._reason_input_active or self._reason_input is None:
539
+ return
540
+ self._reason_input_active = False
541
+ self._reason_input.display = False
542
+ if self._help_widget is not None:
543
+ self._help_widget.update(self._compose_help_text())
544
+ self.focus()
545
+
546
+ def on_input_submitted(self, event: Input.Submitted) -> None:
547
+ """Submit the reject decision with the typed reason (if any)."""
548
+ # Stop before the guard so a stray submit (e.g. queued after Esc closed
549
+ # the input) cannot bubble to a parent and be re-interpreted, and so a
550
+ # foreign Input's submission is never misrouted through this handler.
551
+ if event.input is not self._reason_input:
552
+ return
553
+ event.stop()
554
+ if not self._reason_input_active:
555
+ logger.debug(
556
+ "on_input_submitted fired with inactive reason input; dropping"
557
+ )
558
+ return
559
+ reason = event.value.strip()
560
+ self._reason_input_active = False
561
+ self._handle_selection(2, reject_message=reason or None)
562
+
563
+ def _collect_security_warnings(self) -> list[str]:
564
+ """Collect warning strings for suspicious Unicode and URL values.
565
+
566
+ Recursively inspects all nested string values in action arguments.
567
+
568
+ Returns:
569
+ Warning strings for the current action request batch.
570
+ """
571
+ warnings: list[str] = []
572
+ for action_request in self._action_requests:
573
+ tool_name = str(action_request.get("name", "unknown"))
574
+ args = action_request.get("args", {})
575
+ if not isinstance(args, dict):
576
+ continue
577
+ for arg_path, text in iter_string_values(args):
578
+ issues = detect_dangerous_unicode(text)
579
+ if issues:
580
+ warnings.append(
581
+ f"{tool_name}.{arg_path}: hidden Unicode "
582
+ f"({summarize_issues(issues)})"
583
+ )
584
+ if looks_like_url_key(arg_path):
585
+ result = check_url_safety(text)
586
+ if result.safe:
587
+ continue
588
+ detail = format_warning_detail(result.warnings)
589
+ if result.decoded_domain:
590
+ detail = f"{detail}; decoded host: {result.decoded_domain}"
591
+ warnings.append(f"{tool_name}.{arg_path}: {detail}")
592
+ return warnings
593
+
594
+ def on_blur(self, event: events.Blur) -> None: # noqa: ARG002 # Textual event handler signature
595
+ """Re-focus on blur to keep focus trapped until decision is made.
596
+
597
+ Skipped while the free-text reject input is active so the `Input`
598
+ widget can keep keyboard focus.
599
+ """
600
+ if self._reason_input_active:
601
+ return
602
+ self.call_after_refresh(self.focus)