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.
Files changed (126) hide show
  1. omnimancer/__init__.py +42 -0
  2. omnimancer/__main__.py +9 -0
  3. omnimancer/cli/__init__.py +8 -0
  4. omnimancer/cli/agent_manager.py +826 -0
  5. omnimancer/cli/agent_wizard.py +944 -0
  6. omnimancer/cli/approval_formatter.py +734 -0
  7. omnimancer/cli/approval_integration.py +734 -0
  8. omnimancer/cli/approval_prompt.py +1286 -0
  9. omnimancer/cli/batch_approval_display.py +617 -0
  10. omnimancer/cli/batch_approval_filters.py +729 -0
  11. omnimancer/cli/batch_operation_monitor.py +775 -0
  12. omnimancer/cli/commands/performance_cmd.py +624 -0
  13. omnimancer/cli/commands.py +757 -0
  14. omnimancer/cli/completion_handler.py +281 -0
  15. omnimancer/cli/config_setup_wizard.py +783 -0
  16. omnimancer/cli/dynamic_commands.py +371 -0
  17. omnimancer/cli/handlers/__init__.py +18 -0
  18. omnimancer/cli/handlers/agent_handler.py +865 -0
  19. omnimancer/cli/handlers/agent_persona_handler.py +1048 -0
  20. omnimancer/cli/handlers/custom_agent_handler.py +352 -0
  21. omnimancer/cli/handlers/permissions_handler.py +1609 -0
  22. omnimancer/cli/interface.py +6695 -0
  23. omnimancer/cli/performance_dashboard.py +612 -0
  24. omnimancer/core/__init__.py +34 -0
  25. omnimancer/core/agent/__init__.py +256 -0
  26. omnimancer/core/agent/agent_config.py +715 -0
  27. omnimancer/core/agent/agent_switcher.py +668 -0
  28. omnimancer/core/agent/approval_context.py +460 -0
  29. omnimancer/core/agent/approval_dialog.py +902 -0
  30. omnimancer/core/agent/approval_interface.py +628 -0
  31. omnimancer/core/agent/approval_manager.py +760 -0
  32. omnimancer/core/agent/cli_integration.py +470 -0
  33. omnimancer/core/agent/config.py +845 -0
  34. omnimancer/core/agent/custom_agent_integration.py +654 -0
  35. omnimancer/core/agent/diff_renderer.py +706 -0
  36. omnimancer/core/agent/file_content_display.py +503 -0
  37. omnimancer/core/agent/file_modification_workflow.py +658 -0
  38. omnimancer/core/agent/file_system_manager.py +1790 -0
  39. omnimancer/core/agent/input_handler.py +614 -0
  40. omnimancer/core/agent/metrics_collector.py +771 -0
  41. omnimancer/core/agent/optimization_engine.py +729 -0
  42. omnimancer/core/agent/performance_monitor.py +717 -0
  43. omnimancer/core/agent/persona.py +870 -0
  44. omnimancer/core/agent/persona_validator.py +672 -0
  45. omnimancer/core/agent/program_executor.py +748 -0
  46. omnimancer/core/agent/proposed_changes_integration.py +663 -0
  47. omnimancer/core/agent/read_before_write_errors.py +492 -0
  48. omnimancer/core/agent/read_before_write_ui.py +647 -0
  49. omnimancer/core/agent/rich_renderer.py +763 -0
  50. omnimancer/core/agent/status_config.py +238 -0
  51. omnimancer/core/agent/status_core.py +446 -0
  52. omnimancer/core/agent/status_integrated_engine.py +540 -0
  53. omnimancer/core/agent/status_manager.py +878 -0
  54. omnimancer/core/agent/status_ui_components.py +535 -0
  55. omnimancer/core/agent/template_applicator.py +548 -0
  56. omnimancer/core/agent/terminal_status_listener.py +394 -0
  57. omnimancer/core/agent/token_tracker.py +679 -0
  58. omnimancer/core/agent/types.py +60 -0
  59. omnimancer/core/agent/web_client.py +745 -0
  60. omnimancer/core/agent/workflow_orchestrator.py +826 -0
  61. omnimancer/core/agent_engine.py +1625 -0
  62. omnimancer/core/agent_mode_manager.py +706 -0
  63. omnimancer/core/agent_progress_ui.py +545 -0
  64. omnimancer/core/chat_manager.py +141 -0
  65. omnimancer/core/config_manager.py +2199 -0
  66. omnimancer/core/config_migration.py +772 -0
  67. omnimancer/core/config_provider.py +1974 -0
  68. omnimancer/core/config_security_validator.py +393 -0
  69. omnimancer/core/config_validator.py +768 -0
  70. omnimancer/core/conversation_manager.py +352 -0
  71. omnimancer/core/engine.py +787 -0
  72. omnimancer/core/enhanced_input.py +523 -0
  73. omnimancer/core/env_loader.py +83 -0
  74. omnimancer/core/fallback_manager.py +568 -0
  75. omnimancer/core/health_monitor.py +462 -0
  76. omnimancer/core/history_manager.py +462 -0
  77. omnimancer/core/mcp_integration_layer.py +878 -0
  78. omnimancer/core/models.py +1889 -0
  79. omnimancer/core/provider_capabilities.py +403 -0
  80. omnimancer/core/provider_initializer.py +450 -0
  81. omnimancer/core/provider_registry.py +432 -0
  82. omnimancer/core/security/__init__.py +16 -0
  83. omnimancer/core/security/approval_workflow.py +406 -0
  84. omnimancer/core/security/audit_logger.py +511 -0
  85. omnimancer/core/security/permission_controller.py +511 -0
  86. omnimancer/core/security/sandbox_manager.py +379 -0
  87. omnimancer/core/security/security_manager.py +530 -0
  88. omnimancer/core/setup_wizard.py +345 -0
  89. omnimancer/core/setup_wizard_core.py +449 -0
  90. omnimancer/core/setup_wizard_guide_generator.py +376 -0
  91. omnimancer/core/setup_wizard_provider_setup.py +454 -0
  92. omnimancer/core/setup_wizard_ui.py +317 -0
  93. omnimancer/core/setup_wizard_validation.py +371 -0
  94. omnimancer/core/signal_handler.py +174 -0
  95. omnimancer/mcp/__init__.py +11 -0
  96. omnimancer/mcp/client.py +725 -0
  97. omnimancer/mcp/manager.py +649 -0
  98. omnimancer/providers/__init__.py +24 -0
  99. omnimancer/providers/azure.py +608 -0
  100. omnimancer/providers/base.py +262 -0
  101. omnimancer/providers/bedrock.py +711 -0
  102. omnimancer/providers/claude.py +426 -0
  103. omnimancer/providers/claude_code.py +406 -0
  104. omnimancer/providers/cohere.py +412 -0
  105. omnimancer/providers/factory.py +988 -0
  106. omnimancer/providers/gemini.py +708 -0
  107. omnimancer/providers/mistral.py +554 -0
  108. omnimancer/providers/ollama.py +510 -0
  109. omnimancer/providers/openai.py +362 -0
  110. omnimancer/providers/openrouter.py +709 -0
  111. omnimancer/providers/perplexity.py +476 -0
  112. omnimancer/providers/vertex.py +617 -0
  113. omnimancer/providers/xai.py +484 -0
  114. omnimancer/ui/__init__.py +6 -0
  115. omnimancer/ui/cancellation_handler.py +235 -0
  116. omnimancer/ui/progress_indicator.py +318 -0
  117. omnimancer/utils/__init__.py +22 -0
  118. omnimancer/utils/error_handler.py +589 -0
  119. omnimancer/utils/errors.py +667 -0
  120. omnimancer/utils/retry.py +238 -0
  121. omnimancer_cli-0.1.0.dist-info/METADATA +413 -0
  122. omnimancer_cli-0.1.0.dist-info/RECORD +126 -0
  123. omnimancer_cli-0.1.0.dist-info/WHEEL +5 -0
  124. omnimancer_cli-0.1.0.dist-info/entry_points.txt +4 -0
  125. omnimancer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  126. 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
+ )