omnimancer-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.
- omnimancer/__init__.py +42 -0
- omnimancer/__main__.py +9 -0
- omnimancer/cli/__init__.py +8 -0
- omnimancer/cli/agent_manager.py +826 -0
- omnimancer/cli/agent_wizard.py +944 -0
- omnimancer/cli/approval_formatter.py +734 -0
- omnimancer/cli/approval_integration.py +734 -0
- omnimancer/cli/approval_prompt.py +1286 -0
- omnimancer/cli/batch_approval_display.py +617 -0
- omnimancer/cli/batch_approval_filters.py +729 -0
- omnimancer/cli/batch_operation_monitor.py +775 -0
- omnimancer/cli/commands/performance_cmd.py +624 -0
- omnimancer/cli/commands.py +757 -0
- omnimancer/cli/completion_handler.py +281 -0
- omnimancer/cli/config_setup_wizard.py +783 -0
- omnimancer/cli/dynamic_commands.py +371 -0
- omnimancer/cli/handlers/__init__.py +18 -0
- omnimancer/cli/handlers/agent_handler.py +865 -0
- omnimancer/cli/handlers/agent_persona_handler.py +1048 -0
- omnimancer/cli/handlers/custom_agent_handler.py +352 -0
- omnimancer/cli/handlers/permissions_handler.py +1609 -0
- omnimancer/cli/interface.py +6695 -0
- omnimancer/cli/performance_dashboard.py +612 -0
- omnimancer/core/__init__.py +34 -0
- omnimancer/core/agent/__init__.py +256 -0
- omnimancer/core/agent/agent_config.py +715 -0
- omnimancer/core/agent/agent_switcher.py +668 -0
- omnimancer/core/agent/approval_context.py +460 -0
- omnimancer/core/agent/approval_dialog.py +902 -0
- omnimancer/core/agent/approval_interface.py +628 -0
- omnimancer/core/agent/approval_manager.py +760 -0
- omnimancer/core/agent/cli_integration.py +470 -0
- omnimancer/core/agent/config.py +845 -0
- omnimancer/core/agent/custom_agent_integration.py +654 -0
- omnimancer/core/agent/diff_renderer.py +706 -0
- omnimancer/core/agent/file_content_display.py +503 -0
- omnimancer/core/agent/file_modification_workflow.py +658 -0
- omnimancer/core/agent/file_system_manager.py +1790 -0
- omnimancer/core/agent/input_handler.py +614 -0
- omnimancer/core/agent/metrics_collector.py +771 -0
- omnimancer/core/agent/optimization_engine.py +729 -0
- omnimancer/core/agent/performance_monitor.py +717 -0
- omnimancer/core/agent/persona.py +870 -0
- omnimancer/core/agent/persona_validator.py +672 -0
- omnimancer/core/agent/program_executor.py +748 -0
- omnimancer/core/agent/proposed_changes_integration.py +663 -0
- omnimancer/core/agent/read_before_write_errors.py +492 -0
- omnimancer/core/agent/read_before_write_ui.py +647 -0
- omnimancer/core/agent/rich_renderer.py +763 -0
- omnimancer/core/agent/status_config.py +238 -0
- omnimancer/core/agent/status_core.py +446 -0
- omnimancer/core/agent/status_integrated_engine.py +540 -0
- omnimancer/core/agent/status_manager.py +878 -0
- omnimancer/core/agent/status_ui_components.py +535 -0
- omnimancer/core/agent/template_applicator.py +548 -0
- omnimancer/core/agent/terminal_status_listener.py +394 -0
- omnimancer/core/agent/token_tracker.py +679 -0
- omnimancer/core/agent/types.py +60 -0
- omnimancer/core/agent/web_client.py +745 -0
- omnimancer/core/agent/workflow_orchestrator.py +826 -0
- omnimancer/core/agent_engine.py +1625 -0
- omnimancer/core/agent_mode_manager.py +706 -0
- omnimancer/core/agent_progress_ui.py +545 -0
- omnimancer/core/chat_manager.py +141 -0
- omnimancer/core/config_manager.py +2199 -0
- omnimancer/core/config_migration.py +772 -0
- omnimancer/core/config_provider.py +1974 -0
- omnimancer/core/config_security_validator.py +393 -0
- omnimancer/core/config_validator.py +768 -0
- omnimancer/core/conversation_manager.py +352 -0
- omnimancer/core/engine.py +787 -0
- omnimancer/core/enhanced_input.py +523 -0
- omnimancer/core/env_loader.py +83 -0
- omnimancer/core/fallback_manager.py +568 -0
- omnimancer/core/health_monitor.py +462 -0
- omnimancer/core/history_manager.py +462 -0
- omnimancer/core/mcp_integration_layer.py +878 -0
- omnimancer/core/models.py +1889 -0
- omnimancer/core/provider_capabilities.py +403 -0
- omnimancer/core/provider_initializer.py +450 -0
- omnimancer/core/provider_registry.py +432 -0
- omnimancer/core/security/__init__.py +16 -0
- omnimancer/core/security/approval_workflow.py +406 -0
- omnimancer/core/security/audit_logger.py +511 -0
- omnimancer/core/security/permission_controller.py +511 -0
- omnimancer/core/security/sandbox_manager.py +379 -0
- omnimancer/core/security/security_manager.py +530 -0
- omnimancer/core/setup_wizard.py +345 -0
- omnimancer/core/setup_wizard_core.py +449 -0
- omnimancer/core/setup_wizard_guide_generator.py +376 -0
- omnimancer/core/setup_wizard_provider_setup.py +454 -0
- omnimancer/core/setup_wizard_ui.py +317 -0
- omnimancer/core/setup_wizard_validation.py +371 -0
- omnimancer/core/signal_handler.py +174 -0
- omnimancer/mcp/__init__.py +11 -0
- omnimancer/mcp/client.py +725 -0
- omnimancer/mcp/manager.py +649 -0
- omnimancer/providers/__init__.py +24 -0
- omnimancer/providers/azure.py +608 -0
- omnimancer/providers/base.py +262 -0
- omnimancer/providers/bedrock.py +711 -0
- omnimancer/providers/claude.py +426 -0
- omnimancer/providers/claude_code.py +406 -0
- omnimancer/providers/cohere.py +412 -0
- omnimancer/providers/factory.py +988 -0
- omnimancer/providers/gemini.py +708 -0
- omnimancer/providers/mistral.py +554 -0
- omnimancer/providers/ollama.py +510 -0
- omnimancer/providers/openai.py +362 -0
- omnimancer/providers/openrouter.py +709 -0
- omnimancer/providers/perplexity.py +476 -0
- omnimancer/providers/vertex.py +617 -0
- omnimancer/providers/xai.py +484 -0
- omnimancer/ui/__init__.py +6 -0
- omnimancer/ui/cancellation_handler.py +235 -0
- omnimancer/ui/progress_indicator.py +318 -0
- omnimancer/utils/__init__.py +22 -0
- omnimancer/utils/error_handler.py +589 -0
- omnimancer/utils/errors.py +667 -0
- omnimancer/utils/retry.py +238 -0
- omnimancer_cli-0.1.0.dist-info/METADATA +413 -0
- omnimancer_cli-0.1.0.dist-info/RECORD +126 -0
- omnimancer_cli-0.1.0.dist-info/WHEEL +5 -0
- omnimancer_cli-0.1.0.dist-info/entry_points.txt +4 -0
- omnimancer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- omnimancer_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1286 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interactive Approval Prompt Handler for Omnimancer.
|
|
3
|
+
|
|
4
|
+
This module provides an interactive prompt system that presents approval dialogs
|
|
5
|
+
to users and captures their responses including 'Yes', 'Yes and remember', and 'No'
|
|
6
|
+
options with proper keyboard input handling.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import signal
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.panel import Panel
|
|
20
|
+
from rich.syntax import Syntax
|
|
21
|
+
from rich.table import Table
|
|
22
|
+
from rich.text import Text
|
|
23
|
+
|
|
24
|
+
from omnimancer.ui.cancellation_handler import get_active_cancellation_handler
|
|
25
|
+
|
|
26
|
+
from ..core.agent.approval_manager import BatchApprovalRequest, ChangePreview
|
|
27
|
+
from ..core.security.approval_workflow import ApprovalRequest
|
|
28
|
+
from .approval_formatter import CLIApprovalFormatter
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ApprovalDecisionType(Enum):
|
|
34
|
+
"""Types of approval decisions."""
|
|
35
|
+
|
|
36
|
+
APPROVED = "approved"
|
|
37
|
+
APPROVED_AND_REMEMBER = "approved_and_remember"
|
|
38
|
+
REVIEW_AND_EDIT = "review_and_edit"
|
|
39
|
+
DENIED = "denied"
|
|
40
|
+
CANCELLED = "cancelled"
|
|
41
|
+
TIMEOUT = "timeout"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ApprovalDecision:
|
|
46
|
+
"""Represents a user's approval decision."""
|
|
47
|
+
|
|
48
|
+
decision: ApprovalDecisionType
|
|
49
|
+
remember: bool = False
|
|
50
|
+
user_notes: str = ""
|
|
51
|
+
response_time_seconds: float = 0.0
|
|
52
|
+
timeout_occurred: bool = False
|
|
53
|
+
created_at: datetime = None
|
|
54
|
+
|
|
55
|
+
def __post_init__(self):
|
|
56
|
+
if self.created_at is None:
|
|
57
|
+
self.created_at = datetime.now()
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_approved(self) -> bool:
|
|
61
|
+
"""Check if this decision approves the operation."""
|
|
62
|
+
return self.decision in [
|
|
63
|
+
ApprovalDecisionType.APPROVED,
|
|
64
|
+
ApprovalDecisionType.APPROVED_AND_REMEMBER,
|
|
65
|
+
ApprovalDecisionType.REVIEW_AND_EDIT,
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def should_remember(self) -> bool:
|
|
70
|
+
"""Check if this decision should be remembered."""
|
|
71
|
+
return (
|
|
72
|
+
self.decision == ApprovalDecisionType.APPROVED_AND_REMEMBER or self.remember
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class BatchApprovalDecision:
|
|
78
|
+
"""Represents a batch approval decision."""
|
|
79
|
+
|
|
80
|
+
decision_type: str # "approve_all", "deny_all", "selective", "individual"
|
|
81
|
+
approved_indices: List[int] = None
|
|
82
|
+
user_notes: str = ""
|
|
83
|
+
individual_decisions: List[ApprovalDecision] = None
|
|
84
|
+
created_at: datetime = None
|
|
85
|
+
|
|
86
|
+
def __post_init__(self):
|
|
87
|
+
if self.created_at is None:
|
|
88
|
+
self.created_at = datetime.now()
|
|
89
|
+
if self.approved_indices is None:
|
|
90
|
+
self.approved_indices = []
|
|
91
|
+
if self.individual_decisions is None:
|
|
92
|
+
self.individual_decisions = []
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class CLIApprovalPrompt:
|
|
96
|
+
"""
|
|
97
|
+
Interactive prompt system for approval dialogs.
|
|
98
|
+
|
|
99
|
+
Integrates with CLIApprovalFormatter to display approval dialogs and capture
|
|
100
|
+
user input with keyboard shortcuts, timeout handling, and graceful cancellation.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
console: Optional[Console] = None,
|
|
106
|
+
formatter: Optional[CLIApprovalFormatter] = None,
|
|
107
|
+
default_timeout_seconds: int = 300,
|
|
108
|
+
): # 5 minutes default
|
|
109
|
+
"""
|
|
110
|
+
Initialize the approval prompt handler.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
console: Rich Console instance
|
|
114
|
+
formatter: CLIApprovalFormatter for display
|
|
115
|
+
default_timeout_seconds: Default timeout for approval prompts
|
|
116
|
+
"""
|
|
117
|
+
self.console = console or Console()
|
|
118
|
+
self.formatter = formatter or CLIApprovalFormatter(self.console)
|
|
119
|
+
self.default_timeout_seconds = default_timeout_seconds
|
|
120
|
+
|
|
121
|
+
# Signal handling for graceful interruption
|
|
122
|
+
self._original_sigint_handler = None
|
|
123
|
+
self._interrupted = False
|
|
124
|
+
|
|
125
|
+
# Valid response mappings
|
|
126
|
+
self.approval_responses = {
|
|
127
|
+
"y": ApprovalDecisionType.APPROVED,
|
|
128
|
+
"yes": ApprovalDecisionType.APPROVED,
|
|
129
|
+
"approve": ApprovalDecisionType.APPROVED,
|
|
130
|
+
"1": ApprovalDecisionType.APPROVED, # Support legacy numeric responses
|
|
131
|
+
"r": ApprovalDecisionType.APPROVED_AND_REMEMBER, # Remember decision
|
|
132
|
+
"remember": ApprovalDecisionType.APPROVED_AND_REMEMBER,
|
|
133
|
+
"e": ApprovalDecisionType.REVIEW_AND_EDIT, # Edit content
|
|
134
|
+
"edit": ApprovalDecisionType.REVIEW_AND_EDIT,
|
|
135
|
+
"2": ApprovalDecisionType.REVIEW_AND_EDIT, # Support legacy numeric responses
|
|
136
|
+
"n": ApprovalDecisionType.DENIED,
|
|
137
|
+
"no": ApprovalDecisionType.DENIED,
|
|
138
|
+
"reject": ApprovalDecisionType.DENIED,
|
|
139
|
+
"3": ApprovalDecisionType.DENIED, # Support legacy numeric responses
|
|
140
|
+
"q": ApprovalDecisionType.CANCELLED,
|
|
141
|
+
"quit": ApprovalDecisionType.CANCELLED,
|
|
142
|
+
"cancel": ApprovalDecisionType.CANCELLED,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async def prompt_for_file_modification_approval(
|
|
146
|
+
self,
|
|
147
|
+
review_data: Dict[str, Any],
|
|
148
|
+
current_model: str = "Unknown",
|
|
149
|
+
timeout_seconds: Optional[int] = None,
|
|
150
|
+
) -> Dict[str, Any]:
|
|
151
|
+
"""
|
|
152
|
+
Present file modification approval with unified UI.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
review_data: File modification data
|
|
156
|
+
current_model: Current AI model name for UI headers
|
|
157
|
+
timeout_seconds: Timeout in seconds
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Dictionary with approval decision and any modified content
|
|
161
|
+
"""
|
|
162
|
+
start_time = datetime.now()
|
|
163
|
+
timeout = timeout_seconds or self.default_timeout_seconds
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
# Set up interrupt handling
|
|
167
|
+
self._setup_interrupt_handling()
|
|
168
|
+
|
|
169
|
+
# Display file modification preview with current model
|
|
170
|
+
self._display_file_modification_preview(review_data, current_model)
|
|
171
|
+
|
|
172
|
+
# Get user decision with timeout
|
|
173
|
+
decision = await self._get_file_modification_decision_with_timeout(
|
|
174
|
+
review_data, timeout
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Calculate response time
|
|
178
|
+
response_time = (datetime.now() - start_time).total_seconds()
|
|
179
|
+
decision["response_time_seconds"] = response_time
|
|
180
|
+
|
|
181
|
+
return decision
|
|
182
|
+
|
|
183
|
+
except KeyboardInterrupt:
|
|
184
|
+
self.console.print(
|
|
185
|
+
"\n[red]❌ File modification cancelled by user (Ctrl+C)[/red]"
|
|
186
|
+
)
|
|
187
|
+
return {
|
|
188
|
+
"approved": False,
|
|
189
|
+
"reason": "Cancelled by user (Ctrl+C)",
|
|
190
|
+
"response_time_seconds": (datetime.now() - start_time).total_seconds(),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
except asyncio.TimeoutError:
|
|
194
|
+
self.console.print(
|
|
195
|
+
f"\n[red]⏰ File modification timed out after {timeout} seconds - Operation denied[/red]"
|
|
196
|
+
)
|
|
197
|
+
return {
|
|
198
|
+
"approved": False,
|
|
199
|
+
"reason": f"Timed out after {timeout} seconds",
|
|
200
|
+
"response_time_seconds": timeout,
|
|
201
|
+
"timeout_occurred": True,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
except Exception as e:
|
|
205
|
+
logger.error(f"Error in file modification approval: {e}")
|
|
206
|
+
return {
|
|
207
|
+
"approved": False,
|
|
208
|
+
"reason": f"Error occurred: {str(e)}",
|
|
209
|
+
"response_time_seconds": (datetime.now() - start_time).total_seconds(),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
finally:
|
|
213
|
+
# Restore original interrupt handling
|
|
214
|
+
self._restore_interrupt_handling()
|
|
215
|
+
|
|
216
|
+
async def prompt_for_approval(
|
|
217
|
+
self,
|
|
218
|
+
approval_request: ApprovalRequest,
|
|
219
|
+
preview: Optional[ChangePreview] = None,
|
|
220
|
+
operation_data: Optional[Dict[str, Any]] = None,
|
|
221
|
+
timeout_seconds: Optional[int] = None,
|
|
222
|
+
) -> ApprovalDecision:
|
|
223
|
+
"""
|
|
224
|
+
Present approval dialog and capture user decision.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
approval_request: The approval request to present
|
|
228
|
+
preview: Optional change preview with diff information
|
|
229
|
+
operation_data: Additional operation data for context
|
|
230
|
+
timeout_seconds: Timeout in seconds (uses default if None)
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
ApprovalDecision with user's choice
|
|
234
|
+
"""
|
|
235
|
+
start_time = datetime.now()
|
|
236
|
+
timeout = timeout_seconds or self.default_timeout_seconds
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
# Set up interrupt handling
|
|
240
|
+
self._setup_interrupt_handling()
|
|
241
|
+
|
|
242
|
+
# Display the approval dialog
|
|
243
|
+
dialog = self.formatter.format_approval_dialog(
|
|
244
|
+
approval_request, preview, operation_data
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
self.console.print(dialog)
|
|
248
|
+
self.console.print() # Extra line for spacing
|
|
249
|
+
|
|
250
|
+
# Get user input with timeout
|
|
251
|
+
decision = await self._get_user_decision_with_timeout(timeout)
|
|
252
|
+
|
|
253
|
+
# Handle special case for "remember" - confirm user understands
|
|
254
|
+
if decision.decision == ApprovalDecisionType.APPROVED_AND_REMEMBER:
|
|
255
|
+
if await self._confirm_remember_decision(approval_request):
|
|
256
|
+
decision.remember = True
|
|
257
|
+
else:
|
|
258
|
+
# User declined remember, convert to regular approval
|
|
259
|
+
decision.decision = ApprovalDecisionType.APPROVED
|
|
260
|
+
decision.remember = False
|
|
261
|
+
|
|
262
|
+
# Calculate response time
|
|
263
|
+
response_time = (datetime.now() - start_time).total_seconds()
|
|
264
|
+
decision.response_time_seconds = response_time
|
|
265
|
+
|
|
266
|
+
# Display confirmation
|
|
267
|
+
if decision.is_approved:
|
|
268
|
+
confirmation = self.formatter.format_approval_approved(
|
|
269
|
+
approval_request, decision.should_remember
|
|
270
|
+
)
|
|
271
|
+
else:
|
|
272
|
+
confirmation = self.formatter.format_approval_denied(
|
|
273
|
+
approval_request, self._get_decision_reason(decision)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
self.console.print(confirmation)
|
|
277
|
+
|
|
278
|
+
return decision
|
|
279
|
+
|
|
280
|
+
except KeyboardInterrupt:
|
|
281
|
+
# Handle Ctrl+C gracefully with cleanup
|
|
282
|
+
response_time = (datetime.now() - start_time).total_seconds()
|
|
283
|
+
decision = ApprovalDecision(
|
|
284
|
+
decision=ApprovalDecisionType.CANCELLED,
|
|
285
|
+
user_notes="Cancelled by user (Ctrl+C)",
|
|
286
|
+
response_time_seconds=response_time,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
self.console.print("\n[red]❌ Operation cancelled by user (Ctrl+C)[/red]")
|
|
290
|
+
|
|
291
|
+
# Log cancellation details for debugging
|
|
292
|
+
logger.info(
|
|
293
|
+
f"Approval cancelled after {response_time:.2f} seconds: {approval_request.operation_type}"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Perform any necessary cleanup
|
|
297
|
+
await self._handle_cancellation_cleanup(approval_request, "user_interrupt")
|
|
298
|
+
|
|
299
|
+
return decision
|
|
300
|
+
|
|
301
|
+
except asyncio.TimeoutError:
|
|
302
|
+
# Handle timeout with cleanup
|
|
303
|
+
decision = ApprovalDecision(
|
|
304
|
+
decision=ApprovalDecisionType.TIMEOUT,
|
|
305
|
+
timeout_occurred=True,
|
|
306
|
+
user_notes=f"Timed out after {timeout} seconds",
|
|
307
|
+
response_time_seconds=timeout,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
self.console.print(
|
|
311
|
+
f"\n[red]⏰ Approval timed out after {timeout} seconds - Operation denied[/red]"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Log timeout details
|
|
315
|
+
logger.warning(
|
|
316
|
+
f"Approval timeout after {timeout}s: {approval_request.operation_type}"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Perform cleanup for timeout
|
|
320
|
+
await self._handle_cancellation_cleanup(approval_request, "timeout")
|
|
321
|
+
|
|
322
|
+
return decision
|
|
323
|
+
|
|
324
|
+
except Exception as e:
|
|
325
|
+
logger.error(f"Error in approval prompt: {e}")
|
|
326
|
+
decision = ApprovalDecision(
|
|
327
|
+
decision=ApprovalDecisionType.DENIED,
|
|
328
|
+
user_notes=f"Error occurred: {str(e)}",
|
|
329
|
+
response_time_seconds=(datetime.now() - start_time).total_seconds(),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
self.console.print(f"[red]❌ Error in approval prompt: {e}[/red]")
|
|
333
|
+
return decision
|
|
334
|
+
|
|
335
|
+
finally:
|
|
336
|
+
# Restore original interrupt handling
|
|
337
|
+
self._restore_interrupt_handling()
|
|
338
|
+
|
|
339
|
+
async def prompt_for_batch_approval(
|
|
340
|
+
self,
|
|
341
|
+
batch_request: BatchApprovalRequest,
|
|
342
|
+
timeout_seconds: Optional[int] = None,
|
|
343
|
+
) -> BatchApprovalDecision:
|
|
344
|
+
"""
|
|
345
|
+
Present batch approval dialog and capture user decisions.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
batch_request: BatchApprovalRequest with multiple operations
|
|
349
|
+
timeout_seconds: Timeout in seconds (uses default if None)
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
BatchApprovalDecision with user's choices
|
|
353
|
+
"""
|
|
354
|
+
timeout = timeout_seconds or self.default_timeout_seconds
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
self._setup_interrupt_handling()
|
|
358
|
+
|
|
359
|
+
# Display batch approval dialog
|
|
360
|
+
dialog = self.formatter.format_batch_approval_dialog(batch_request)
|
|
361
|
+
self.console.print(dialog)
|
|
362
|
+
self.console.print()
|
|
363
|
+
|
|
364
|
+
# Get batch decision
|
|
365
|
+
batch_decision = await self._get_batch_decision_with_timeout(
|
|
366
|
+
batch_request, timeout
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# Display confirmation
|
|
370
|
+
self._display_batch_confirmation(batch_decision, batch_request)
|
|
371
|
+
|
|
372
|
+
return batch_decision
|
|
373
|
+
|
|
374
|
+
except KeyboardInterrupt:
|
|
375
|
+
self.console.print("\n[red]❌ Batch approval cancelled by user[/red]")
|
|
376
|
+
return BatchApprovalDecision(
|
|
377
|
+
decision_type="cancelled",
|
|
378
|
+
user_notes="Cancelled by user (Ctrl+C)",
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
except asyncio.TimeoutError:
|
|
382
|
+
self.console.print(
|
|
383
|
+
f"\n[red]⏰ Batch approval timed out after {timeout} seconds[/red]"
|
|
384
|
+
)
|
|
385
|
+
return BatchApprovalDecision(
|
|
386
|
+
decision_type="timeout",
|
|
387
|
+
user_notes=f"Timed out after {timeout} seconds",
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
finally:
|
|
391
|
+
self._restore_interrupt_handling()
|
|
392
|
+
|
|
393
|
+
async def _get_user_decision_with_timeout(
|
|
394
|
+
self, timeout_seconds: int
|
|
395
|
+
) -> ApprovalDecision:
|
|
396
|
+
"""Get user decision with timeout handling."""
|
|
397
|
+
try:
|
|
398
|
+
# Use asyncio.wait_for for timeout
|
|
399
|
+
response = await asyncio.wait_for(
|
|
400
|
+
self._get_user_input_async(
|
|
401
|
+
"Enter your decision (y=approve/r=remember/e=edit/n=reject/q=quit): "
|
|
402
|
+
),
|
|
403
|
+
timeout=timeout_seconds,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
response_lower = response.strip().lower()
|
|
407
|
+
|
|
408
|
+
# Map response to decision type
|
|
409
|
+
decision_type = self.approval_responses.get(response_lower)
|
|
410
|
+
|
|
411
|
+
if decision_type is None:
|
|
412
|
+
# Invalid response, ask again with clearer options
|
|
413
|
+
self.console.print("[yellow]Invalid response. Please enter:[/yellow]")
|
|
414
|
+
self.console.print(" [green]y[/green] = Yes (approve)")
|
|
415
|
+
self.console.print(
|
|
416
|
+
" [blue]r[/blue] = Remember (approve and auto-approve similar)"
|
|
417
|
+
)
|
|
418
|
+
self.console.print(
|
|
419
|
+
" [cyan]e[/cyan] = Edit (review and modify content)"
|
|
420
|
+
)
|
|
421
|
+
self.console.print(" [red]n[/red] = No (reject)")
|
|
422
|
+
self.console.print(
|
|
423
|
+
" [yellow]q[/yellow] = Quit (cancel and exit to omnimancer prompt)"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
# Recursive call with reduced timeout
|
|
427
|
+
remaining_timeout = max(timeout_seconds - 10, 30)
|
|
428
|
+
return await self._get_user_decision_with_timeout(remaining_timeout)
|
|
429
|
+
|
|
430
|
+
return ApprovalDecision(decision=decision_type)
|
|
431
|
+
|
|
432
|
+
except asyncio.TimeoutError:
|
|
433
|
+
raise # Re-raise timeout for upper level handling
|
|
434
|
+
|
|
435
|
+
async def _get_batch_decision_with_timeout(
|
|
436
|
+
self, batch_request: BatchApprovalRequest, timeout_seconds: int
|
|
437
|
+
) -> BatchApprovalDecision:
|
|
438
|
+
"""Get batch decision with timeout handling."""
|
|
439
|
+
try:
|
|
440
|
+
response = await asyncio.wait_for(
|
|
441
|
+
self._get_user_input_async(
|
|
442
|
+
"Enter batch decision (all/none/select/individual/q): "
|
|
443
|
+
),
|
|
444
|
+
timeout=timeout_seconds,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
response_lower = response.strip().lower()
|
|
448
|
+
|
|
449
|
+
if response_lower in ["all", "approve-all", "yes-all"]:
|
|
450
|
+
return BatchApprovalDecision(
|
|
451
|
+
decision_type="approve_all",
|
|
452
|
+
approved_indices=list(range(len(batch_request.operations))),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
elif response_lower in ["none", "deny-all", "no-all"]:
|
|
456
|
+
return BatchApprovalDecision(decision_type="deny_all")
|
|
457
|
+
|
|
458
|
+
elif response_lower in ["select", "selective", "s"]:
|
|
459
|
+
return await self._handle_selective_approval(
|
|
460
|
+
batch_request, timeout_seconds
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
elif response_lower in ["individual", "one-by-one", "i"]:
|
|
464
|
+
return await self._handle_individual_approval(
|
|
465
|
+
batch_request, timeout_seconds
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
elif response_lower in ["q", "quit", "cancel"]:
|
|
469
|
+
return BatchApprovalDecision(
|
|
470
|
+
decision_type="cancelled", user_notes="Cancelled by user"
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
else:
|
|
474
|
+
# Invalid response
|
|
475
|
+
self.console.print("[yellow]Invalid response. Please enter:[/yellow]")
|
|
476
|
+
self.console.print(" [green]all[/green] = Approve all operations")
|
|
477
|
+
self.console.print(" [red]none[/red] = Deny all operations")
|
|
478
|
+
self.console.print(" [blue]select[/blue] = Choose specific operations")
|
|
479
|
+
self.console.print(
|
|
480
|
+
" [yellow]individual[/yellow] = Review each operation separately"
|
|
481
|
+
)
|
|
482
|
+
self.console.print(" [yellow]q[/yellow] = Cancel batch")
|
|
483
|
+
|
|
484
|
+
remaining_timeout = max(timeout_seconds - 15, 30)
|
|
485
|
+
return await self._get_batch_decision_with_timeout(
|
|
486
|
+
batch_request, remaining_timeout
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
except asyncio.TimeoutError:
|
|
490
|
+
raise
|
|
491
|
+
|
|
492
|
+
async def _handle_selective_approval(
|
|
493
|
+
self, batch_request: BatchApprovalRequest, timeout_seconds: int
|
|
494
|
+
) -> BatchApprovalDecision:
|
|
495
|
+
"""Handle selective operation approval."""
|
|
496
|
+
operation_count = len(batch_request.operations)
|
|
497
|
+
|
|
498
|
+
self.console.print(
|
|
499
|
+
f"\n[blue]Select operations to approve (1-{operation_count}):[/blue]"
|
|
500
|
+
)
|
|
501
|
+
self.console.print(
|
|
502
|
+
"Enter numbers separated by spaces or commas (e.g., '1 3 5' or '1,3,5')"
|
|
503
|
+
)
|
|
504
|
+
self.console.print("Or enter ranges (e.g., '1-3 5 7-9')")
|
|
505
|
+
|
|
506
|
+
try:
|
|
507
|
+
response = await asyncio.wait_for(
|
|
508
|
+
self._get_user_input_async(
|
|
509
|
+
f"Select operations (1-{operation_count}): "
|
|
510
|
+
),
|
|
511
|
+
timeout=timeout_seconds,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
selected_indices = self._parse_operation_selection(
|
|
515
|
+
response, operation_count
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
if selected_indices is None:
|
|
519
|
+
# Invalid selection, try again
|
|
520
|
+
self.console.print("[red]Invalid selection format[/red]")
|
|
521
|
+
return await self._handle_selective_approval(
|
|
522
|
+
batch_request, timeout_seconds - 10
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
return BatchApprovalDecision(
|
|
526
|
+
decision_type="selective", approved_indices=selected_indices
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
except asyncio.TimeoutError:
|
|
530
|
+
raise
|
|
531
|
+
|
|
532
|
+
async def _handle_individual_approval(
|
|
533
|
+
self, batch_request: BatchApprovalRequest, timeout_seconds: int
|
|
534
|
+
) -> BatchApprovalDecision:
|
|
535
|
+
"""Handle individual operation approval."""
|
|
536
|
+
individual_decisions = []
|
|
537
|
+
approved_indices = []
|
|
538
|
+
|
|
539
|
+
self.console.print(
|
|
540
|
+
"\n[blue]Individual approval mode - reviewing each operation:[/blue]"
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
for i, (operation, preview) in enumerate(
|
|
544
|
+
zip(batch_request.operations, batch_request.previews)
|
|
545
|
+
):
|
|
546
|
+
self.console.print(
|
|
547
|
+
f"\n[bold]--- Operation {i + 1} of {len(batch_request.operations)} ---[/bold]"
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
# Create individual approval request
|
|
551
|
+
individual_request = ApprovalRequest(
|
|
552
|
+
operation_type=operation.type.value,
|
|
553
|
+
description=operation.description,
|
|
554
|
+
metadata=operation.data,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# Get approval for this individual operation
|
|
558
|
+
decision = await self.prompt_for_approval(
|
|
559
|
+
individual_request,
|
|
560
|
+
preview,
|
|
561
|
+
operation.data,
|
|
562
|
+
timeout_seconds=min(timeout_seconds, 120), # 2 minute max per operation
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
individual_decisions.append(decision)
|
|
566
|
+
|
|
567
|
+
if decision.is_approved:
|
|
568
|
+
approved_indices.append(i)
|
|
569
|
+
|
|
570
|
+
# Check if user wants to cancel the rest
|
|
571
|
+
if decision.decision == ApprovalDecisionType.CANCELLED:
|
|
572
|
+
self.console.print("[yellow]Cancelled remaining operations[/yellow]")
|
|
573
|
+
break
|
|
574
|
+
|
|
575
|
+
return BatchApprovalDecision(
|
|
576
|
+
decision_type="individual",
|
|
577
|
+
approved_indices=approved_indices,
|
|
578
|
+
individual_decisions=individual_decisions,
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
def _parse_operation_selection(
|
|
582
|
+
self, response: str, max_operations: int
|
|
583
|
+
) -> Optional[List[int]]:
|
|
584
|
+
"""Parse operation selection string into list of indices."""
|
|
585
|
+
try:
|
|
586
|
+
indices = set()
|
|
587
|
+
|
|
588
|
+
# Replace commas with spaces and split
|
|
589
|
+
parts = response.replace(",", " ").split()
|
|
590
|
+
|
|
591
|
+
for part in parts:
|
|
592
|
+
part = part.strip()
|
|
593
|
+
if not part:
|
|
594
|
+
continue
|
|
595
|
+
|
|
596
|
+
if "-" in part:
|
|
597
|
+
# Handle ranges like "1-3"
|
|
598
|
+
range_parts = part.split("-")
|
|
599
|
+
if len(range_parts) != 2:
|
|
600
|
+
return None
|
|
601
|
+
|
|
602
|
+
start = int(range_parts[0])
|
|
603
|
+
end = int(range_parts[1])
|
|
604
|
+
|
|
605
|
+
if start < 1 or end > max_operations or start > end:
|
|
606
|
+
return None
|
|
607
|
+
|
|
608
|
+
indices.update(range(start - 1, end)) # Convert to 0-based indexing
|
|
609
|
+
else:
|
|
610
|
+
# Handle individual numbers
|
|
611
|
+
num = int(part)
|
|
612
|
+
if num < 1 or num > max_operations:
|
|
613
|
+
return None
|
|
614
|
+
indices.add(num - 1) # Convert to 0-based indexing
|
|
615
|
+
|
|
616
|
+
return sorted(list(indices))
|
|
617
|
+
|
|
618
|
+
except ValueError:
|
|
619
|
+
return None
|
|
620
|
+
|
|
621
|
+
async def _confirm_remember_decision(
|
|
622
|
+
self, approval_request: ApprovalRequest
|
|
623
|
+
) -> bool:
|
|
624
|
+
"""Confirm user wants to remember this approval decision."""
|
|
625
|
+
self.console.print()
|
|
626
|
+
self.console.print("[blue]🧠 Remember Decision Confirmation[/blue]")
|
|
627
|
+
self.console.print(
|
|
628
|
+
f"You chose to remember this approval for similar '{approval_request.operation_type}' operations."
|
|
629
|
+
)
|
|
630
|
+
self.console.print(
|
|
631
|
+
"This means similar operations will be automatically approved without asking."
|
|
632
|
+
)
|
|
633
|
+
self.console.print()
|
|
634
|
+
|
|
635
|
+
try:
|
|
636
|
+
response = await asyncio.wait_for(
|
|
637
|
+
self._get_user_input_async(
|
|
638
|
+
"Are you sure you want to remember this? (y/n): "
|
|
639
|
+
),
|
|
640
|
+
timeout=30, # 30 second timeout for confirmation
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
return response.strip().lower() in ["y", "yes", "true", "1"]
|
|
644
|
+
|
|
645
|
+
except asyncio.TimeoutError:
|
|
646
|
+
self.console.print(
|
|
647
|
+
"[yellow]Confirmation timed out - not remembering decision[/yellow]"
|
|
648
|
+
)
|
|
649
|
+
return False
|
|
650
|
+
|
|
651
|
+
async def _get_user_input_async(self, prompt_text: str) -> str:
|
|
652
|
+
"""Get user input asynchronously."""
|
|
653
|
+
|
|
654
|
+
# Use asyncio.to_thread for truly async input
|
|
655
|
+
def get_input():
|
|
656
|
+
return input(prompt_text)
|
|
657
|
+
|
|
658
|
+
try:
|
|
659
|
+
return await asyncio.to_thread(get_input)
|
|
660
|
+
except KeyboardInterrupt:
|
|
661
|
+
# Handle Ctrl+C during input
|
|
662
|
+
raise
|
|
663
|
+
|
|
664
|
+
def _setup_interrupt_handling(self):
|
|
665
|
+
"""Set up signal handling for graceful interruption."""
|
|
666
|
+
|
|
667
|
+
def signal_handler(signum, frame):
|
|
668
|
+
self._interrupted = True
|
|
669
|
+
# Don't call sys.exit here, let the calling code handle it
|
|
670
|
+
raise KeyboardInterrupt("User interrupted with Ctrl+C")
|
|
671
|
+
|
|
672
|
+
self._original_sigint_handler = signal.signal(signal.SIGINT, signal_handler)
|
|
673
|
+
|
|
674
|
+
def _restore_interrupt_handling(self):
|
|
675
|
+
"""Restore original signal handling."""
|
|
676
|
+
if self._original_sigint_handler is not None:
|
|
677
|
+
signal.signal(signal.SIGINT, self._original_sigint_handler)
|
|
678
|
+
self._original_sigint_handler = None
|
|
679
|
+
|
|
680
|
+
def _get_decision_reason(self, decision: ApprovalDecision) -> str:
|
|
681
|
+
"""Get human-readable reason for decision."""
|
|
682
|
+
if decision.decision == ApprovalDecisionType.DENIED:
|
|
683
|
+
return decision.user_notes or "User denied operation"
|
|
684
|
+
elif decision.decision == ApprovalDecisionType.CANCELLED:
|
|
685
|
+
return decision.user_notes or "User cancelled operation"
|
|
686
|
+
elif decision.decision == ApprovalDecisionType.TIMEOUT:
|
|
687
|
+
return decision.user_notes or "Operation timed out"
|
|
688
|
+
else:
|
|
689
|
+
return decision.user_notes or ""
|
|
690
|
+
|
|
691
|
+
def _display_batch_confirmation(
|
|
692
|
+
self,
|
|
693
|
+
batch_decision: BatchApprovalDecision,
|
|
694
|
+
batch_request: BatchApprovalRequest,
|
|
695
|
+
):
|
|
696
|
+
"""Display confirmation of batch decision."""
|
|
697
|
+
if batch_decision.decision_type == "approve_all":
|
|
698
|
+
self.console.print(
|
|
699
|
+
f"[green]✅ Approved all {len(batch_request.operations)} operations[/green]"
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
elif batch_decision.decision_type == "deny_all":
|
|
703
|
+
self.console.print(
|
|
704
|
+
f"[red]❌ Denied all {len(batch_request.operations)} operations[/red]"
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
elif batch_decision.decision_type == "selective":
|
|
708
|
+
approved_count = len(batch_decision.approved_indices)
|
|
709
|
+
total_count = len(batch_request.operations)
|
|
710
|
+
self.console.print(
|
|
711
|
+
f"[blue]✅ Approved {approved_count} of {total_count} operations[/blue]"
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
# Show which operations were approved
|
|
715
|
+
if approved_count > 0:
|
|
716
|
+
approved_nums = [str(i + 1) for i in batch_decision.approved_indices]
|
|
717
|
+
self.console.print(
|
|
718
|
+
f" Approved operations: {', '.join(approved_nums)}"
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
elif batch_decision.decision_type == "individual":
|
|
722
|
+
approved_count = len(batch_decision.approved_indices)
|
|
723
|
+
total_count = len(batch_request.operations)
|
|
724
|
+
self.console.print(
|
|
725
|
+
f"[blue]✅ Individual review complete: {approved_count} of {total_count} approved[/blue]"
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
elif batch_decision.decision_type in ["cancelled", "timeout"]:
|
|
729
|
+
self.console.print("[yellow]❌ Batch approval cancelled[/yellow]")
|
|
730
|
+
|
|
731
|
+
async def _handle_cancellation_cleanup(
|
|
732
|
+
self, approval_request: ApprovalRequest, reason: str
|
|
733
|
+
):
|
|
734
|
+
"""
|
|
735
|
+
Handle cleanup tasks when an approval is cancelled or times out.
|
|
736
|
+
|
|
737
|
+
Args:
|
|
738
|
+
approval_request: The approval request that was cancelled
|
|
739
|
+
reason: Reason for cancellation ("user_interrupt", "timeout", etc.)
|
|
740
|
+
"""
|
|
741
|
+
try:
|
|
742
|
+
# Log the cancellation for debugging
|
|
743
|
+
logger.debug(
|
|
744
|
+
f"Performing cancellation cleanup for {approval_request.operation_type} - Reason: {reason}"
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
# Clean up any temporary resources that might have been allocated
|
|
748
|
+
# during the approval process
|
|
749
|
+
|
|
750
|
+
# If there are any file handles, locks, or other resources
|
|
751
|
+
# related to the approval request, clean them up here
|
|
752
|
+
|
|
753
|
+
# Record cancellation metrics for later analysis
|
|
754
|
+
cancellation_data = {
|
|
755
|
+
"operation_type": approval_request.operation_type,
|
|
756
|
+
"reason": reason,
|
|
757
|
+
"timestamp": datetime.now().isoformat(),
|
|
758
|
+
"risk_level": (
|
|
759
|
+
approval_request.risk_level.value
|
|
760
|
+
if approval_request.risk_level
|
|
761
|
+
else "unknown"
|
|
762
|
+
),
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
# In a full implementation, this could be sent to a metrics system
|
|
766
|
+
logger.info(f"Approval cancellation recorded: {cancellation_data}")
|
|
767
|
+
|
|
768
|
+
except Exception as e:
|
|
769
|
+
logger.error(f"Error during cancellation cleanup: {e}")
|
|
770
|
+
# Don't re-raise - cleanup errors shouldn't fail the cancellation
|
|
771
|
+
|
|
772
|
+
def cleanup_resources(self):
|
|
773
|
+
"""
|
|
774
|
+
Clean up any resources held by the approval prompt handler.
|
|
775
|
+
|
|
776
|
+
This method should be called when the approval prompt handler
|
|
777
|
+
is no longer needed to ensure proper resource cleanup.
|
|
778
|
+
"""
|
|
779
|
+
try:
|
|
780
|
+
# Restore interrupt handling if still set
|
|
781
|
+
self._restore_interrupt_handling()
|
|
782
|
+
|
|
783
|
+
# Clear any cached data
|
|
784
|
+
self._interrupted = False
|
|
785
|
+
|
|
786
|
+
# Any other cleanup tasks
|
|
787
|
+
logger.debug("Approval prompt handler resources cleaned up")
|
|
788
|
+
|
|
789
|
+
except Exception as e:
|
|
790
|
+
logger.error(f"Error during resource cleanup: {e}")
|
|
791
|
+
|
|
792
|
+
def _display_file_modification_preview(
|
|
793
|
+
self, review_data: Dict[str, Any], current_model: str
|
|
794
|
+
):
|
|
795
|
+
"""Display file modification preview using current model name."""
|
|
796
|
+
# Ensure we start on a new line, clearing any spinner artifacts
|
|
797
|
+
self.console.print()
|
|
798
|
+
|
|
799
|
+
# Display header with current model name
|
|
800
|
+
self._display_modification_header(review_data, current_model)
|
|
801
|
+
|
|
802
|
+
# Show content preview based on operation type
|
|
803
|
+
if review_data["operation"] == "create":
|
|
804
|
+
self._display_new_file_preview(review_data)
|
|
805
|
+
else:
|
|
806
|
+
self._display_modification_preview(review_data)
|
|
807
|
+
|
|
808
|
+
def _display_modification_header(
|
|
809
|
+
self, review_data: Dict[str, Any], current_model: str
|
|
810
|
+
):
|
|
811
|
+
"""Display header information about the file modification."""
|
|
812
|
+
file_path = review_data["file_path"]
|
|
813
|
+
operation = review_data["operation"]
|
|
814
|
+
|
|
815
|
+
# Create header panel with current model
|
|
816
|
+
header_text = Text()
|
|
817
|
+
header_text.append(
|
|
818
|
+
f"{current_model} - File Modification Review\n", style="bold cyan"
|
|
819
|
+
)
|
|
820
|
+
header_text.append(f"Operation: ", style="bold")
|
|
821
|
+
header_text.append(f"{operation.title()}\n", style="yellow")
|
|
822
|
+
header_text.append(f"File: ", style="bold")
|
|
823
|
+
header_text.append(f"{file_path}", style="green")
|
|
824
|
+
|
|
825
|
+
header_panel = Panel(
|
|
826
|
+
header_text,
|
|
827
|
+
title="[bold cyan]File Modification[/bold cyan]",
|
|
828
|
+
border_style="cyan",
|
|
829
|
+
padding=(1, 2),
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
self.console.print(header_panel)
|
|
833
|
+
|
|
834
|
+
def _display_new_file_preview(self, review_data: Dict[str, Any]):
|
|
835
|
+
"""Display preview of new file creation."""
|
|
836
|
+
new_content = review_data.get("new_content", "")
|
|
837
|
+
file_path = Path(review_data["file_path"])
|
|
838
|
+
|
|
839
|
+
# Show content preview with syntax highlighting if possible
|
|
840
|
+
try:
|
|
841
|
+
syntax = Syntax(
|
|
842
|
+
new_content[:2000],
|
|
843
|
+
file_path.suffix.lstrip(".") or "text",
|
|
844
|
+
theme="monokai",
|
|
845
|
+
line_numbers=True,
|
|
846
|
+
)
|
|
847
|
+
preview_panel = Panel(
|
|
848
|
+
syntax,
|
|
849
|
+
title="[bold green]New File Content (Preview)[/bold green]",
|
|
850
|
+
border_style="green",
|
|
851
|
+
)
|
|
852
|
+
except Exception:
|
|
853
|
+
# Fallback to plain text
|
|
854
|
+
preview_content = (
|
|
855
|
+
new_content[:2000] + "..." if len(new_content) > 2000 else new_content
|
|
856
|
+
)
|
|
857
|
+
preview_panel = Panel(
|
|
858
|
+
preview_content,
|
|
859
|
+
title="[bold green]New File Content (Preview)[/bold green]",
|
|
860
|
+
border_style="green",
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
self.console.print(preview_panel)
|
|
864
|
+
|
|
865
|
+
# Show content statistics
|
|
866
|
+
self._display_content_stats(new_content, is_new=True)
|
|
867
|
+
|
|
868
|
+
def _display_modification_preview(self, review_data: Dict[str, Any]):
|
|
869
|
+
"""Display preview of file modification."""
|
|
870
|
+
current_content = review_data.get("current_content", "")
|
|
871
|
+
new_content = review_data.get("new_content", "")
|
|
872
|
+
diff = review_data.get("diff", "")
|
|
873
|
+
|
|
874
|
+
# Show current content summary if it exists
|
|
875
|
+
if current_content:
|
|
876
|
+
self._display_current_content_summary(current_content)
|
|
877
|
+
|
|
878
|
+
# Display diff if available
|
|
879
|
+
if diff:
|
|
880
|
+
self._display_diff(diff)
|
|
881
|
+
else:
|
|
882
|
+
# Fallback to side-by-side preview for very different content
|
|
883
|
+
self._display_side_by_side_preview(current_content, new_content)
|
|
884
|
+
|
|
885
|
+
# Show content statistics
|
|
886
|
+
self._display_content_comparison_stats(current_content, new_content)
|
|
887
|
+
|
|
888
|
+
def _display_current_content_summary(self, current_content: str):
|
|
889
|
+
"""Display summary of current file content."""
|
|
890
|
+
lines = current_content.split("\n")
|
|
891
|
+
total_lines = len(lines)
|
|
892
|
+
|
|
893
|
+
# Show first few and last few lines
|
|
894
|
+
preview_lines = []
|
|
895
|
+
if total_lines <= 20:
|
|
896
|
+
preview_lines = lines
|
|
897
|
+
else:
|
|
898
|
+
preview_lines = (
|
|
899
|
+
lines[:10]
|
|
900
|
+
+ [f"... ({total_lines - 20} lines omitted) ..."]
|
|
901
|
+
+ lines[-10:]
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
preview_text = "\n".join(preview_lines)
|
|
905
|
+
|
|
906
|
+
current_panel = Panel(
|
|
907
|
+
preview_text,
|
|
908
|
+
title="[bold yellow]Current File Content (Preview)[/bold yellow]",
|
|
909
|
+
border_style="yellow",
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
self.console.print(current_panel)
|
|
913
|
+
|
|
914
|
+
def _display_diff(self, diff: str):
|
|
915
|
+
"""Display unified diff with syntax highlighting."""
|
|
916
|
+
# Color code the diff
|
|
917
|
+
diff_text = Text()
|
|
918
|
+
|
|
919
|
+
for line in diff.split("\n"):
|
|
920
|
+
if line.startswith("+++") or line.startswith("---"):
|
|
921
|
+
diff_text.append(line + "\n", style="bold white")
|
|
922
|
+
elif line.startswith("@@"):
|
|
923
|
+
diff_text.append(line + "\n", style="bold cyan")
|
|
924
|
+
elif line.startswith("+"):
|
|
925
|
+
diff_text.append(line + "\n", style="bold green")
|
|
926
|
+
elif line.startswith("-"):
|
|
927
|
+
diff_text.append(line + "\n", style="bold red")
|
|
928
|
+
else:
|
|
929
|
+
diff_text.append(line + "\n", style="white")
|
|
930
|
+
|
|
931
|
+
diff_panel = Panel(
|
|
932
|
+
diff_text,
|
|
933
|
+
title="[bold magenta]Changes (Unified Diff)[/bold magenta]",
|
|
934
|
+
border_style="magenta",
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
self.console.print(diff_panel)
|
|
938
|
+
|
|
939
|
+
def _display_side_by_side_preview(self, current_content: str, new_content: str):
|
|
940
|
+
"""Display side-by-side preview when diff is not available."""
|
|
941
|
+
# Create table for side-by-side view
|
|
942
|
+
table = Table(show_header=True, header_style="bold magenta")
|
|
943
|
+
table.add_column("Current Content", style="yellow", width=50)
|
|
944
|
+
table.add_column("New Content", style="green", width=50)
|
|
945
|
+
|
|
946
|
+
# Truncate content for display
|
|
947
|
+
current_preview = (
|
|
948
|
+
current_content[:1000] + "..."
|
|
949
|
+
if len(current_content) > 1000
|
|
950
|
+
else current_content
|
|
951
|
+
)
|
|
952
|
+
new_preview = (
|
|
953
|
+
new_content[:1000] + "..." if len(new_content) > 1000 else new_content
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
table.add_row(current_preview, new_preview)
|
|
957
|
+
|
|
958
|
+
preview_panel = Panel(
|
|
959
|
+
table,
|
|
960
|
+
title="[bold magenta]Content Comparison[/bold magenta]",
|
|
961
|
+
border_style="magenta",
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
self.console.print(preview_panel)
|
|
965
|
+
|
|
966
|
+
def _display_content_stats(self, content: str, is_new: bool = False):
|
|
967
|
+
"""Display content statistics."""
|
|
968
|
+
lines = content.split("\n")
|
|
969
|
+
chars = len(content)
|
|
970
|
+
words = len(content.split())
|
|
971
|
+
|
|
972
|
+
stats_text = Text()
|
|
973
|
+
stats_text.append("Content Statistics:\n", style="bold")
|
|
974
|
+
stats_text.append(f"Lines: {len(lines)}\n")
|
|
975
|
+
stats_text.append(f"Characters: {chars}\n")
|
|
976
|
+
stats_text.append(f"Words: {words}")
|
|
977
|
+
|
|
978
|
+
title = (
|
|
979
|
+
"[bold blue]New File Statistics[/bold blue]"
|
|
980
|
+
if is_new
|
|
981
|
+
else "[bold blue]Content Statistics[/bold blue]"
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
stats_panel = Panel(stats_text, title=title, border_style="blue", width=40)
|
|
985
|
+
|
|
986
|
+
self.console.print(stats_panel)
|
|
987
|
+
|
|
988
|
+
def _display_content_comparison_stats(self, current_content: str, new_content: str):
|
|
989
|
+
"""Display comparison statistics between current and new content."""
|
|
990
|
+
current_lines = len(current_content.split("\n")) if current_content else 0
|
|
991
|
+
new_lines = len(new_content.split("\n")) if new_content else 0
|
|
992
|
+
current_chars = len(current_content) if current_content else 0
|
|
993
|
+
new_chars = len(new_content) if new_content else 0
|
|
994
|
+
|
|
995
|
+
line_change = new_lines - current_lines
|
|
996
|
+
char_change = new_chars - current_chars
|
|
997
|
+
|
|
998
|
+
table = Table(show_header=True, header_style="bold blue")
|
|
999
|
+
table.add_column("Metric", style="bold")
|
|
1000
|
+
table.add_column("Current", style="yellow")
|
|
1001
|
+
table.add_column("New", style="green")
|
|
1002
|
+
table.add_column("Change", style="cyan")
|
|
1003
|
+
|
|
1004
|
+
table.add_row(
|
|
1005
|
+
"Lines",
|
|
1006
|
+
str(current_lines),
|
|
1007
|
+
str(new_lines),
|
|
1008
|
+
f"{'+' if line_change >= 0 else ''}{line_change}",
|
|
1009
|
+
)
|
|
1010
|
+
table.add_row(
|
|
1011
|
+
"Characters",
|
|
1012
|
+
str(current_chars),
|
|
1013
|
+
str(new_chars),
|
|
1014
|
+
f"{'+' if char_change >= 0 else ''}{char_change}",
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
stats_panel = Panel(
|
|
1018
|
+
table,
|
|
1019
|
+
title="[bold blue]Content Comparison[/bold blue]",
|
|
1020
|
+
border_style="blue",
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
self.console.print(stats_panel)
|
|
1024
|
+
|
|
1025
|
+
async def _get_file_modification_decision_with_timeout(
|
|
1026
|
+
self, review_data: Dict[str, Any], timeout_seconds: int
|
|
1027
|
+
) -> Dict[str, Any]:
|
|
1028
|
+
"""Get user decision about file modification with timeout."""
|
|
1029
|
+
self.console.print() # Add some spacing
|
|
1030
|
+
|
|
1031
|
+
# Present options to user
|
|
1032
|
+
options_text = Text()
|
|
1033
|
+
options_text.append("Options:\n", style="bold cyan")
|
|
1034
|
+
options_text.append("y. ", style="bold green")
|
|
1035
|
+
options_text.append("Approve changes\n", style="green")
|
|
1036
|
+
options_text.append("r. ", style="bold blue")
|
|
1037
|
+
options_text.append(
|
|
1038
|
+
"Remember (approve and auto-approve similar)\n", style="blue"
|
|
1039
|
+
)
|
|
1040
|
+
options_text.append("e. ", style="bold cyan")
|
|
1041
|
+
options_text.append("Edit content before writing\n", style="cyan")
|
|
1042
|
+
options_text.append("n. ", style="bold red")
|
|
1043
|
+
options_text.append("Reject changes\n", style="red")
|
|
1044
|
+
options_text.append("q. ", style="bold yellow")
|
|
1045
|
+
options_text.append(
|
|
1046
|
+
"Quit (cancel and exit to omnimancer prompt)\n", style="yellow"
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
options_panel = Panel(
|
|
1050
|
+
options_text,
|
|
1051
|
+
title="[bold cyan]Choose Action[/bold cyan]",
|
|
1052
|
+
border_style="cyan",
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
self.console.print(options_panel)
|
|
1056
|
+
|
|
1057
|
+
# Pause the spinner during user interaction
|
|
1058
|
+
cancellation_handler = get_active_cancellation_handler()
|
|
1059
|
+
if cancellation_handler:
|
|
1060
|
+
cancellation_handler.pause_status_display()
|
|
1061
|
+
|
|
1062
|
+
try:
|
|
1063
|
+
# Get user choice with timeout
|
|
1064
|
+
response = await asyncio.wait_for(
|
|
1065
|
+
self._get_user_input_async("Enter your decision (y/r/e/n/q): "),
|
|
1066
|
+
timeout=timeout_seconds,
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
response_lower = response.strip().lower()
|
|
1070
|
+
|
|
1071
|
+
# Process the user's choice
|
|
1072
|
+
if response_lower in ["y", "yes", "approve", "1"]:
|
|
1073
|
+
return {"approved": True, "reason": "User approved changes"}
|
|
1074
|
+
elif response_lower in ["r", "remember"]:
|
|
1075
|
+
return {
|
|
1076
|
+
"approved": True,
|
|
1077
|
+
"remember": True,
|
|
1078
|
+
"reason": "User approved and remembered decision",
|
|
1079
|
+
}
|
|
1080
|
+
elif response_lower in ["e", "edit", "2"]:
|
|
1081
|
+
return await self._handle_edit_content(review_data)
|
|
1082
|
+
elif response_lower in ["n", "no", "reject", "3"]:
|
|
1083
|
+
# Ask for optional reason
|
|
1084
|
+
try:
|
|
1085
|
+
reason_response = await asyncio.wait_for(
|
|
1086
|
+
self._get_user_input_async("Reason for rejection (optional): "),
|
|
1087
|
+
timeout=30,
|
|
1088
|
+
)
|
|
1089
|
+
reason = reason_response.strip() or "User rejected changes"
|
|
1090
|
+
except asyncio.TimeoutError:
|
|
1091
|
+
reason = "User rejected changes"
|
|
1092
|
+
|
|
1093
|
+
return {"approved": False, "reason": reason}
|
|
1094
|
+
elif response_lower in ["q", "quit", "cancel"]:
|
|
1095
|
+
return {
|
|
1096
|
+
"approved": False,
|
|
1097
|
+
"reason": "User cancelled operation",
|
|
1098
|
+
"cancelled": True,
|
|
1099
|
+
}
|
|
1100
|
+
else:
|
|
1101
|
+
# Invalid response, show help and try again
|
|
1102
|
+
self.console.print("[yellow]Invalid response. Please enter:[/yellow]")
|
|
1103
|
+
self.console.print(" [green]y[/green] = Yes (approve)")
|
|
1104
|
+
self.console.print(
|
|
1105
|
+
" [blue]r[/blue] = Remember (approve and auto-approve similar)"
|
|
1106
|
+
)
|
|
1107
|
+
self.console.print(
|
|
1108
|
+
" [cyan]e[/cyan] = Edit (review and modify content)"
|
|
1109
|
+
)
|
|
1110
|
+
self.console.print(" [red]n[/red] = No (reject)")
|
|
1111
|
+
self.console.print(
|
|
1112
|
+
" [yellow]q[/yellow] = Quit (cancel and exit to omnimancer prompt)"
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
# Retry with reduced timeout
|
|
1116
|
+
remaining_timeout = max(timeout_seconds - 10, 30)
|
|
1117
|
+
return await self._get_file_modification_decision_with_timeout(
|
|
1118
|
+
review_data, remaining_timeout
|
|
1119
|
+
)
|
|
1120
|
+
|
|
1121
|
+
except asyncio.TimeoutError:
|
|
1122
|
+
raise # Re-raise timeout for upper level handling
|
|
1123
|
+
|
|
1124
|
+
finally:
|
|
1125
|
+
# Resume the spinner after user interaction
|
|
1126
|
+
if cancellation_handler:
|
|
1127
|
+
cancellation_handler.resume_status_display()
|
|
1128
|
+
|
|
1129
|
+
async def _handle_edit_content(self, review_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
1130
|
+
"""Handle user editing of content before writing."""
|
|
1131
|
+
self.console.print("\n[yellow]Content editing functionality:[/yellow]")
|
|
1132
|
+
self.console.print("You can:")
|
|
1133
|
+
self.console.print("1. Provide replacement content inline")
|
|
1134
|
+
self.console.print("2. Open external editor (if configured)")
|
|
1135
|
+
self.console.print("3. Go back to original content")
|
|
1136
|
+
|
|
1137
|
+
try:
|
|
1138
|
+
edit_choice = await asyncio.wait_for(
|
|
1139
|
+
self._get_user_input_async("Edit method (1=inline/2=editor/3=back): "),
|
|
1140
|
+
timeout=60,
|
|
1141
|
+
)
|
|
1142
|
+
|
|
1143
|
+
edit_choice_lower = edit_choice.strip().lower()
|
|
1144
|
+
|
|
1145
|
+
if edit_choice_lower in ["1", "inline"]:
|
|
1146
|
+
return await self._inline_content_edit(review_data)
|
|
1147
|
+
elif edit_choice_lower in ["2", "editor"]:
|
|
1148
|
+
return await self._external_editor_edit(review_data)
|
|
1149
|
+
else:
|
|
1150
|
+
# Go back to original decision
|
|
1151
|
+
return await self._get_file_modification_decision_with_timeout(
|
|
1152
|
+
review_data, 300
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
except asyncio.TimeoutError:
|
|
1156
|
+
self.console.print(
|
|
1157
|
+
"[yellow]Edit choice timed out - going back to approval options[/yellow]"
|
|
1158
|
+
)
|
|
1159
|
+
return await self._get_file_modification_decision_with_timeout(
|
|
1160
|
+
review_data, 300
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
async def _inline_content_edit(self, review_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
1164
|
+
"""Handle inline content editing."""
|
|
1165
|
+
self.console.print("\n[yellow]Inline content editing:[/yellow]")
|
|
1166
|
+
self.console.print(
|
|
1167
|
+
"Enter new content (type END_CONTENT on a new line to finish):"
|
|
1168
|
+
)
|
|
1169
|
+
|
|
1170
|
+
lines = []
|
|
1171
|
+
while True:
|
|
1172
|
+
try:
|
|
1173
|
+
line = await asyncio.to_thread(input)
|
|
1174
|
+
if line.strip() == "END_CONTENT":
|
|
1175
|
+
break
|
|
1176
|
+
lines.append(line)
|
|
1177
|
+
except EOFError:
|
|
1178
|
+
break
|
|
1179
|
+
except KeyboardInterrupt:
|
|
1180
|
+
self.console.print("\n[red]Edit cancelled[/red]")
|
|
1181
|
+
return {"approved": False, "reason": "Edit cancelled by user"}
|
|
1182
|
+
|
|
1183
|
+
modified_content = "\n".join(lines)
|
|
1184
|
+
|
|
1185
|
+
# Confirm the edit
|
|
1186
|
+
self.console.print(
|
|
1187
|
+
f"\n[cyan]Modified content ({len(modified_content)} characters):[/cyan]"
|
|
1188
|
+
)
|
|
1189
|
+
preview = (
|
|
1190
|
+
modified_content[:500] + "..."
|
|
1191
|
+
if len(modified_content) > 500
|
|
1192
|
+
else modified_content
|
|
1193
|
+
)
|
|
1194
|
+
self.console.print(Panel(preview, title="Modified Content Preview"))
|
|
1195
|
+
|
|
1196
|
+
try:
|
|
1197
|
+
confirm_response = await asyncio.wait_for(
|
|
1198
|
+
self._get_user_input_async("Use this modified content? (y/n): "),
|
|
1199
|
+
timeout=30,
|
|
1200
|
+
)
|
|
1201
|
+
|
|
1202
|
+
if confirm_response.strip().lower() in ["y", "yes"]:
|
|
1203
|
+
return {
|
|
1204
|
+
"approved": True,
|
|
1205
|
+
"modified_content": modified_content,
|
|
1206
|
+
"reason": "User provided modified content",
|
|
1207
|
+
}
|
|
1208
|
+
else:
|
|
1209
|
+
return await self._get_file_modification_decision_with_timeout(
|
|
1210
|
+
review_data, 300
|
|
1211
|
+
)
|
|
1212
|
+
|
|
1213
|
+
except asyncio.TimeoutError:
|
|
1214
|
+
self.console.print(
|
|
1215
|
+
"[yellow]Confirmation timed out - using modified content[/yellow]"
|
|
1216
|
+
)
|
|
1217
|
+
return {
|
|
1218
|
+
"approved": True,
|
|
1219
|
+
"modified_content": modified_content,
|
|
1220
|
+
"reason": "User provided modified content (auto-confirmed due to timeout)",
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
async def _external_editor_edit(
|
|
1224
|
+
self, review_data: Dict[str, Any]
|
|
1225
|
+
) -> Dict[str, Any]:
|
|
1226
|
+
"""Handle external editor content editing."""
|
|
1227
|
+
self.console.print(
|
|
1228
|
+
"[yellow]External editor functionality not yet implemented[/yellow]"
|
|
1229
|
+
)
|
|
1230
|
+
self.console.print("[yellow]Falling back to inline editing...[/yellow]")
|
|
1231
|
+
return await self._inline_content_edit(review_data)
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
# Utility functions for easy integration
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
async def prompt_single_approval(
|
|
1238
|
+
approval_request: ApprovalRequest,
|
|
1239
|
+
preview: Optional[ChangePreview] = None,
|
|
1240
|
+
operation_data: Optional[Dict[str, Any]] = None,
|
|
1241
|
+
console: Optional[Console] = None,
|
|
1242
|
+
timeout_seconds: int = 300,
|
|
1243
|
+
) -> ApprovalDecision:
|
|
1244
|
+
"""
|
|
1245
|
+
Convenience function for single approval prompts.
|
|
1246
|
+
|
|
1247
|
+
Args:
|
|
1248
|
+
approval_request: The approval request
|
|
1249
|
+
preview: Optional change preview
|
|
1250
|
+
operation_data: Optional operation data
|
|
1251
|
+
console: Rich console (creates new if None)
|
|
1252
|
+
timeout_seconds: Timeout in seconds
|
|
1253
|
+
|
|
1254
|
+
Returns:
|
|
1255
|
+
ApprovalDecision with user's choice
|
|
1256
|
+
"""
|
|
1257
|
+
prompt_handler = CLIApprovalPrompt(
|
|
1258
|
+
console=console, default_timeout_seconds=timeout_seconds
|
|
1259
|
+
)
|
|
1260
|
+
return await prompt_handler.prompt_for_approval(
|
|
1261
|
+
approval_request, preview, operation_data, timeout_seconds
|
|
1262
|
+
)
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
async def prompt_batch_approval(
|
|
1266
|
+
batch_request: BatchApprovalRequest,
|
|
1267
|
+
console: Optional[Console] = None,
|
|
1268
|
+
timeout_seconds: int = 600,
|
|
1269
|
+
) -> BatchApprovalDecision:
|
|
1270
|
+
"""
|
|
1271
|
+
Convenience function for batch approval prompts.
|
|
1272
|
+
|
|
1273
|
+
Args:
|
|
1274
|
+
batch_request: The batch approval request
|
|
1275
|
+
console: Rich console (creates new if None)
|
|
1276
|
+
timeout_seconds: Timeout in seconds
|
|
1277
|
+
|
|
1278
|
+
Returns:
|
|
1279
|
+
BatchApprovalDecision with user's choices
|
|
1280
|
+
"""
|
|
1281
|
+
prompt_handler = CLIApprovalPrompt(
|
|
1282
|
+
console=console, default_timeout_seconds=timeout_seconds
|
|
1283
|
+
)
|
|
1284
|
+
return await prompt_handler.prompt_for_batch_approval(
|
|
1285
|
+
batch_request, timeout_seconds
|
|
1286
|
+
)
|