kollabor 0.4.9__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 (128) hide show
  1. core/__init__.py +18 -0
  2. core/application.py +578 -0
  3. core/cli.py +193 -0
  4. core/commands/__init__.py +43 -0
  5. core/commands/executor.py +277 -0
  6. core/commands/menu_renderer.py +319 -0
  7. core/commands/parser.py +186 -0
  8. core/commands/registry.py +331 -0
  9. core/commands/system_commands.py +479 -0
  10. core/config/__init__.py +7 -0
  11. core/config/llm_task_config.py +110 -0
  12. core/config/loader.py +501 -0
  13. core/config/manager.py +112 -0
  14. core/config/plugin_config_manager.py +346 -0
  15. core/config/plugin_schema.py +424 -0
  16. core/config/service.py +399 -0
  17. core/effects/__init__.py +1 -0
  18. core/events/__init__.py +12 -0
  19. core/events/bus.py +129 -0
  20. core/events/executor.py +154 -0
  21. core/events/models.py +258 -0
  22. core/events/processor.py +176 -0
  23. core/events/registry.py +289 -0
  24. core/fullscreen/__init__.py +19 -0
  25. core/fullscreen/command_integration.py +290 -0
  26. core/fullscreen/components/__init__.py +12 -0
  27. core/fullscreen/components/animation.py +258 -0
  28. core/fullscreen/components/drawing.py +160 -0
  29. core/fullscreen/components/matrix_components.py +177 -0
  30. core/fullscreen/manager.py +302 -0
  31. core/fullscreen/plugin.py +204 -0
  32. core/fullscreen/renderer.py +282 -0
  33. core/fullscreen/session.py +324 -0
  34. core/io/__init__.py +52 -0
  35. core/io/buffer_manager.py +362 -0
  36. core/io/config_status_view.py +272 -0
  37. core/io/core_status_views.py +410 -0
  38. core/io/input_errors.py +313 -0
  39. core/io/input_handler.py +2655 -0
  40. core/io/input_mode_manager.py +402 -0
  41. core/io/key_parser.py +344 -0
  42. core/io/layout.py +587 -0
  43. core/io/message_coordinator.py +204 -0
  44. core/io/message_renderer.py +601 -0
  45. core/io/modal_interaction_handler.py +315 -0
  46. core/io/raw_input_processor.py +946 -0
  47. core/io/status_renderer.py +845 -0
  48. core/io/terminal_renderer.py +586 -0
  49. core/io/terminal_state.py +551 -0
  50. core/io/visual_effects.py +734 -0
  51. core/llm/__init__.py +26 -0
  52. core/llm/api_communication_service.py +863 -0
  53. core/llm/conversation_logger.py +473 -0
  54. core/llm/conversation_manager.py +414 -0
  55. core/llm/file_operations_executor.py +1401 -0
  56. core/llm/hook_system.py +402 -0
  57. core/llm/llm_service.py +1629 -0
  58. core/llm/mcp_integration.py +386 -0
  59. core/llm/message_display_service.py +450 -0
  60. core/llm/model_router.py +214 -0
  61. core/llm/plugin_sdk.py +396 -0
  62. core/llm/response_parser.py +848 -0
  63. core/llm/response_processor.py +364 -0
  64. core/llm/tool_executor.py +520 -0
  65. core/logging/__init__.py +19 -0
  66. core/logging/setup.py +208 -0
  67. core/models/__init__.py +5 -0
  68. core/models/base.py +23 -0
  69. core/plugins/__init__.py +13 -0
  70. core/plugins/collector.py +212 -0
  71. core/plugins/discovery.py +386 -0
  72. core/plugins/factory.py +263 -0
  73. core/plugins/registry.py +152 -0
  74. core/storage/__init__.py +5 -0
  75. core/storage/state_manager.py +84 -0
  76. core/ui/__init__.py +6 -0
  77. core/ui/config_merger.py +176 -0
  78. core/ui/config_widgets.py +369 -0
  79. core/ui/live_modal_renderer.py +276 -0
  80. core/ui/modal_actions.py +162 -0
  81. core/ui/modal_overlay_renderer.py +373 -0
  82. core/ui/modal_renderer.py +591 -0
  83. core/ui/modal_state_manager.py +443 -0
  84. core/ui/widget_integration.py +222 -0
  85. core/ui/widgets/__init__.py +27 -0
  86. core/ui/widgets/base_widget.py +136 -0
  87. core/ui/widgets/checkbox.py +85 -0
  88. core/ui/widgets/dropdown.py +140 -0
  89. core/ui/widgets/label.py +78 -0
  90. core/ui/widgets/slider.py +185 -0
  91. core/ui/widgets/text_input.py +224 -0
  92. core/utils/__init__.py +11 -0
  93. core/utils/config_utils.py +656 -0
  94. core/utils/dict_utils.py +212 -0
  95. core/utils/error_utils.py +275 -0
  96. core/utils/key_reader.py +171 -0
  97. core/utils/plugin_utils.py +267 -0
  98. core/utils/prompt_renderer.py +151 -0
  99. kollabor-0.4.9.dist-info/METADATA +298 -0
  100. kollabor-0.4.9.dist-info/RECORD +128 -0
  101. kollabor-0.4.9.dist-info/WHEEL +5 -0
  102. kollabor-0.4.9.dist-info/entry_points.txt +2 -0
  103. kollabor-0.4.9.dist-info/licenses/LICENSE +21 -0
  104. kollabor-0.4.9.dist-info/top_level.txt +4 -0
  105. kollabor_cli_main.py +20 -0
  106. plugins/__init__.py +1 -0
  107. plugins/enhanced_input/__init__.py +18 -0
  108. plugins/enhanced_input/box_renderer.py +103 -0
  109. plugins/enhanced_input/box_styles.py +142 -0
  110. plugins/enhanced_input/color_engine.py +165 -0
  111. plugins/enhanced_input/config.py +150 -0
  112. plugins/enhanced_input/cursor_manager.py +72 -0
  113. plugins/enhanced_input/geometry.py +81 -0
  114. plugins/enhanced_input/state.py +130 -0
  115. plugins/enhanced_input/text_processor.py +115 -0
  116. plugins/enhanced_input_plugin.py +385 -0
  117. plugins/fullscreen/__init__.py +9 -0
  118. plugins/fullscreen/example_plugin.py +327 -0
  119. plugins/fullscreen/matrix_plugin.py +132 -0
  120. plugins/hook_monitoring_plugin.py +1299 -0
  121. plugins/query_enhancer_plugin.py +350 -0
  122. plugins/save_conversation_plugin.py +502 -0
  123. plugins/system_commands_plugin.py +93 -0
  124. plugins/tmux_plugin.py +795 -0
  125. plugins/workflow_enforcement_plugin.py +629 -0
  126. system_prompt/default.md +1286 -0
  127. system_prompt/default_win.md +265 -0
  128. system_prompt/example_with_trender.md +47 -0
@@ -0,0 +1,2655 @@
1
+ """Input handling system for Kollabor CLI."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import sys
6
+ import time
7
+ from typing import Dict, Any, List
8
+
9
+ # Platform-specific imports for input handling
10
+ IS_WINDOWS = sys.platform == "win32"
11
+
12
+ if IS_WINDOWS:
13
+ import msvcrt
14
+ else:
15
+ import select
16
+
17
+ from ..events import EventType
18
+ from ..events.models import CommandMode
19
+ from ..commands.parser import SlashCommandParser
20
+ from ..commands.registry import SlashCommandRegistry
21
+ from ..commands.executor import SlashCommandExecutor
22
+ from ..commands.menu_renderer import CommandMenuRenderer
23
+ from .key_parser import KeyParser, KeyPress, KeyType as KeyTypeEnum
24
+ from .buffer_manager import BufferManager
25
+ from .input_errors import InputErrorHandler, ErrorType, ErrorSeverity
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class InputHandler:
31
+ """Advanced terminal input handler with comprehensive key support.
32
+
33
+ Features:
34
+ - Extended key sequence support (arrow keys, function keys)
35
+ - Robust buffer management with validation
36
+ - Advanced error handling and recovery
37
+ - Command history navigation
38
+ - Cursor positioning and editing
39
+ """
40
+
41
+ def __init__(self, event_bus, renderer, config) -> None:
42
+ """Initialize the input handler.
43
+
44
+ Args:
45
+ event_bus: Event bus for emitting input events.
46
+ renderer: Terminal renderer for updating input display.
47
+ config: Configuration manager for input settings.
48
+ """
49
+ self.event_bus = event_bus
50
+ self.renderer = renderer
51
+ self.config = config
52
+ self.running = False
53
+ self.rendering_paused = (
54
+ False # Flag to pause rendering during special effects
55
+ )
56
+
57
+ # Load configurable parameters
58
+ self.polling_delay = config.get("input.polling_delay", 0.01)
59
+ self.error_delay = config.get("input.error_delay", 0.1)
60
+ buffer_limit = config.get(
61
+ "input.input_buffer_limit", 100000
62
+ ) # 100KB limit - wide open!
63
+ history_limit = config.get("input.history_limit", 100)
64
+
65
+ # NOTE: Paste detection has TWO systems:
66
+ # 1. PRIMARY (ALWAYS ON): Large chunk detection (>10 chars)
67
+ # creates "[Pasted #N ...]" placeholders
68
+ # - Located in _input_loop() around line 181
69
+ # - Triggers automatically when terminal sends big chunks
70
+ # - This is what users see when they paste
71
+ # 2. SECONDARY (DISABLED): Character-by-character timing fallback
72
+ # - Located in _process_character() around line 265
73
+ # - Alternative detection method for edge cases
74
+ # - Currently disabled via this flag
75
+ self.paste_detection_enabled = False # Only disables SECONDARY system
76
+
77
+ # Initialize components
78
+ self.key_parser = KeyParser()
79
+ self.buffer_manager = BufferManager(buffer_limit, history_limit)
80
+
81
+ # Initialize slash command system
82
+ self.command_mode = CommandMode.NORMAL
83
+ self.slash_parser = SlashCommandParser()
84
+ self.command_registry = SlashCommandRegistry()
85
+ self.command_executor = SlashCommandExecutor(self.command_registry)
86
+ self.command_menu_renderer = CommandMenuRenderer(self.renderer)
87
+ self.command_menu_active = False
88
+ self.selected_command_index = 0
89
+
90
+ # Initialize modal renderer for modal command mode
91
+ self.modal_renderer = None # Will be initialized when needed
92
+
93
+ # Initialize live modal state (for tmux, logs, etc.)
94
+ self.live_modal_renderer = None # LiveModalRenderer instance when active
95
+ self.live_modal_content_generator = None # Content generator function
96
+ self.live_modal_input_callback = None # Input callback for passthrough
97
+
98
+ # Initialize status modal state
99
+ self.current_status_modal_config = None
100
+ self.error_handler = InputErrorHandler(
101
+ {
102
+ "error_threshold": config.get("input.error_threshold", 10),
103
+ "error_window_minutes": config.get("input.error_window_minutes", 5),
104
+ "max_errors": config.get("input.max_errors", 100),
105
+ }
106
+ )
107
+
108
+ # State tracking
109
+ self._last_cursor_pos = 0
110
+ self._pending_save_confirm = False # For modal save confirmation
111
+
112
+ # Simple paste detection state
113
+ self._paste_buffer = []
114
+ self._last_char_time = 0
115
+ # GENIUS PASTE SYSTEM - immediate synchronous storage
116
+ self._paste_bucket = {} # {paste_id: actual_content}
117
+ self._paste_counter = 0 # Counter for paste numbering
118
+ self._current_paste_id = None # Currently building paste ID
119
+ self._last_paste_time = 0 # Last chunk timestamp
120
+
121
+ logger.info("Input handler initialized with enhanced capabilities")
122
+
123
+ async def start(self) -> None:
124
+ """Start the input handling loop."""
125
+ self.running = True
126
+ self.renderer.enter_raw_mode()
127
+
128
+ # No bracketed paste - your terminal doesn't support it
129
+
130
+ # Check if raw mode worked
131
+ if (
132
+ getattr(
133
+ self.renderer.terminal_state.current_mode,
134
+ "value",
135
+ self.renderer.terminal_state.current_mode,
136
+ )
137
+ != "raw"
138
+ ):
139
+ logger.warning("Raw mode failed - using fallback ESC detection")
140
+
141
+ # Register for COMMAND_MENU_RENDER events
142
+ # to provide command menu display
143
+ logger.info("About to register COMMAND_MENU_RENDER hook")
144
+ await self._register_command_menu_render_hook()
145
+
146
+ # Register for modal trigger events
147
+ logger.info("About to register modal trigger hook")
148
+ await self._register_modal_trigger_hook()
149
+
150
+ # Register for status modal trigger events
151
+ logger.info("About to register status modal trigger hook")
152
+ await self._register_status_modal_trigger_hook()
153
+
154
+ # Register for live modal trigger events
155
+ logger.info("About to register live modal trigger hook")
156
+ await self._register_live_modal_trigger_hook()
157
+
158
+ # Register for status modal render events
159
+ logger.info("About to register status modal render hook")
160
+ await self._register_status_modal_render_hook()
161
+
162
+ # Register for command output display events
163
+ logger.info("About to register command output display hook")
164
+ await self._register_command_output_display_hook()
165
+
166
+ logger.info("All hook registrations completed")
167
+
168
+ logger.info("Input handler started")
169
+ await self._input_loop()
170
+
171
+ async def stop(self) -> None:
172
+ """Stop the input handling loop with cleanup."""
173
+ self.running = False
174
+
175
+ # No bracketed paste to disable
176
+
177
+ await self.cleanup()
178
+ self.renderer.exit_raw_mode()
179
+ logger.info("Input handler stopped")
180
+
181
+ async def _input_loop(self) -> None:
182
+ """Main input processing loop with enhanced error handling."""
183
+ while self.running:
184
+ try:
185
+ # Platform-specific input checking
186
+ has_input = await self._check_input_available()
187
+
188
+ if has_input:
189
+ # Read input data
190
+ chunk = await self._read_input_chunk()
191
+
192
+ if not chunk:
193
+ await asyncio.sleep(self.polling_delay)
194
+ continue
195
+
196
+ # Check if this is an escape sequence (arrow keys, etc.)
197
+ def is_escape_sequence(text: str) -> bool:
198
+ """Check if input is an escape sequence
199
+ that should bypass paste detection."""
200
+ if not text:
201
+ return False
202
+ # Common escape sequences start with ESC (\x1b)
203
+ if text.startswith("\x1b"):
204
+ return True
205
+ return False
206
+
207
+ # PRIMARY PASTE DETECTION:
208
+ # Large chunk detection (ALWAYS ACTIVE)
209
+ # When user pastes, terminal sends all chars
210
+ # in one/few chunks
211
+ # This creates "[Pasted #N X lines, Y chars]" placeholders
212
+ if len(chunk) > 10 and not is_escape_sequence(chunk):
213
+
214
+ import time
215
+
216
+ current_time = time.time()
217
+
218
+ # Check if this continues the current paste (within 100ms)
219
+ if (
220
+ self._current_paste_id
221
+ and self._last_paste_time > 0
222
+ and (current_time - self._last_paste_time) < 0.1
223
+ ):
224
+
225
+ # Merge with existing paste
226
+ self._paste_bucket[self._current_paste_id] += chunk
227
+ self._last_paste_time = current_time
228
+
229
+ # Update the placeholder to show new size
230
+ await self._update_paste_placeholder()
231
+ else:
232
+ # New paste - store immediately
233
+ self._paste_counter += 1
234
+ self._current_paste_id = f"PASTE_{self._paste_counter}"
235
+ self._paste_bucket[self._current_paste_id] = chunk
236
+ self._last_paste_time = current_time
237
+
238
+ # Create placeholder immediately
239
+ await self._create_paste_placeholder(
240
+ self._current_paste_id
241
+ )
242
+ elif is_escape_sequence(chunk):
243
+ # Escape sequence - process character by character
244
+ # to allow key parser to handle it
245
+ logger.debug(
246
+ f"Processing escape sequence "
247
+ f"character-by-character: {repr(chunk)}"
248
+ )
249
+ for char in chunk:
250
+ await self._process_character(char)
251
+ else:
252
+ # Normal input (single or multi-character)
253
+ # process each character individually
254
+ logger.info(
255
+ f"🔤 Processing normal input "
256
+ f"character-by-character: {repr(chunk)}"
257
+ )
258
+ # await self._process_character(chunk)
259
+ for char in chunk:
260
+ await self._process_character(char)
261
+ else:
262
+ # No input available - check for standalone ESC key
263
+ esc_key = self.key_parser.check_for_standalone_escape()
264
+ if esc_key:
265
+ logger.info("DETECTED STANDALONE ESC KEY!")
266
+ # CRITICAL FIX: Route escape to correct handler based on mode
267
+ if self.command_mode == CommandMode.MODAL:
268
+ await self._handle_command_mode_keypress(esc_key)
269
+ elif self.command_mode == CommandMode.LIVE_MODAL:
270
+ await self._handle_live_modal_keypress(esc_key)
271
+ else:
272
+ await self._handle_key_press(esc_key)
273
+
274
+ await asyncio.sleep(self.polling_delay)
275
+
276
+ except KeyboardInterrupt:
277
+ logger.info("Ctrl+C received")
278
+ raise
279
+ except OSError as e:
280
+ await self.error_handler.handle_error(
281
+ ErrorType.IO_ERROR,
282
+ f"I/O error in input loop: {e}",
283
+ ErrorSeverity.HIGH,
284
+ {"buffer_manager": self.buffer_manager},
285
+ )
286
+ await asyncio.sleep(self.error_delay)
287
+ except Exception as e:
288
+ await self.error_handler.handle_error(
289
+ ErrorType.SYSTEM_ERROR,
290
+ f"Unexpected error in input loop: {e}",
291
+ ErrorSeverity.MEDIUM,
292
+ {"buffer_manager": self.buffer_manager},
293
+ )
294
+ await asyncio.sleep(self.error_delay)
295
+
296
+ async def _check_input_available(self) -> bool:
297
+ """Check if input is available (cross-platform).
298
+
299
+ Returns:
300
+ True if input is available, False otherwise.
301
+ """
302
+ if IS_WINDOWS:
303
+ # Windows: Use msvcrt.kbhit() to check for available input
304
+ return msvcrt.kbhit()
305
+ else:
306
+ # Unix: Use select with timeout
307
+ return bool(select.select([sys.stdin], [], [], self.polling_delay)[0])
308
+
309
+ async def _read_input_chunk(self) -> str:
310
+ """Read available input data (cross-platform).
311
+
312
+ Returns:
313
+ Decoded input string, or empty string if no input.
314
+ """
315
+ import os
316
+
317
+ if IS_WINDOWS:
318
+ # Windows: Read all available characters using msvcrt
319
+ chunk = b""
320
+ while msvcrt.kbhit():
321
+ char = msvcrt.getch()
322
+ char_code = char[0] if isinstance(char, bytes) else ord(char)
323
+
324
+ # Handle Windows extended keys (arrow keys, function keys, etc.)
325
+ # Extended keys are prefixed with 0x00 or 0xE0 (224)
326
+ if char_code in (0, 224):
327
+ # Read the actual key code
328
+ ext_char = msvcrt.getch()
329
+ ext_code = ext_char[0] if isinstance(ext_char, bytes) else ord(ext_char)
330
+ # Map Windows extended key codes to ANSI escape sequences
331
+ win_key_map = {
332
+ 72: b"\x1b[A", # ArrowUp
333
+ 80: b"\x1b[B", # ArrowDown
334
+ 75: b"\x1b[D", # ArrowLeft
335
+ 77: b"\x1b[C", # ArrowRight
336
+ 71: b"\x1b[H", # Home
337
+ 79: b"\x1b[F", # End
338
+ 73: b"\x1b[5~", # PageUp
339
+ 81: b"\x1b[6~", # PageDown
340
+ 82: b"\x1b[2~", # Insert
341
+ 83: b"\x1b[3~", # Delete
342
+ 59: b"\x1bOP", # F1
343
+ 60: b"\x1bOQ", # F2
344
+ 61: b"\x1bOR", # F3
345
+ 62: b"\x1bOS", # F4
346
+ 63: b"\x1b[15~", # F5
347
+ 64: b"\x1b[17~", # F6
348
+ 65: b"\x1b[18~", # F7
349
+ 66: b"\x1b[19~", # F8
350
+ 67: b"\x1b[20~", # F9
351
+ 68: b"\x1b[21~", # F10
352
+ 133: b"\x1b[23~", # F11
353
+ 134: b"\x1b[24~", # F12
354
+ }
355
+ if ext_code in win_key_map:
356
+ chunk += win_key_map[ext_code]
357
+ else:
358
+ logger.debug(f"Unknown Windows extended key: {ext_code}")
359
+ else:
360
+ chunk += char
361
+
362
+ # Small delay to allow for more input
363
+ await asyncio.sleep(0.001)
364
+ # Check if there's more data immediately available
365
+ if not msvcrt.kbhit():
366
+ break
367
+ return chunk.decode("utf-8", errors="ignore") if chunk else ""
368
+ else:
369
+ # Unix: Read ALL available data using os.read
370
+ chunk = b""
371
+ while True:
372
+ try:
373
+ # Read in 8KB chunks
374
+ more_data = os.read(0, 8192)
375
+ if not more_data:
376
+ break
377
+ chunk += more_data
378
+ # Check if more data is immediately available
379
+ if not select.select([sys.stdin], [], [], 0.001)[0]:
380
+ break # No more data waiting
381
+ except OSError:
382
+ break # No more data available
383
+ return chunk.decode("utf-8", errors="ignore") if chunk else ""
384
+
385
+ async def _process_character(self, char: str) -> None:
386
+ """Process a single character input.
387
+
388
+ Args:
389
+ char: Character received from terminal.
390
+ """
391
+ try:
392
+ current_time = time.time()
393
+
394
+ # Check for slash command initiation
395
+ # (before parsing for immediate response)
396
+ if (
397
+ char == "/"
398
+ and self.buffer_manager.is_empty
399
+ and self.command_mode == CommandMode.NORMAL
400
+ ):
401
+ await self._enter_command_mode()
402
+ return
403
+
404
+ # SECONDARY PASTE DETECTION:
405
+ # Character-by-character timing (DISABLED)
406
+ # This is a fallback system - primary chunk detection
407
+ # above handles most cases
408
+ if self.paste_detection_enabled:
409
+ # Currently False - secondary system disabled
410
+ paste_handled = await self._simple_paste_detection(
411
+ char, current_time
412
+ )
413
+ if paste_handled:
414
+ # Character consumed by paste detection,
415
+ # skip normal processing
416
+ return
417
+
418
+ # Parse character into structured key press
419
+ # (this handles escape sequences)
420
+ key_press = self.key_parser.parse_char(char)
421
+ if not key_press:
422
+ # For modal mode, add timeout-based
423
+ # standalone escape detection
424
+ if self.command_mode == CommandMode.MODAL:
425
+ # Schedule delayed check for standalone escape
426
+ # (100ms delay)
427
+ async def delayed_escape_check():
428
+ await asyncio.sleep(0.1)
429
+ standalone_escape = (
430
+ self.key_parser.check_for_standalone_escape()
431
+ )
432
+ if standalone_escape:
433
+ await self._handle_command_mode_keypress(
434
+ standalone_escape
435
+ )
436
+
437
+ asyncio.create_task(delayed_escape_check())
438
+ # Incomplete escape sequence - wait for more characters
439
+ return
440
+
441
+ # Check for slash command mode handling AFTER parsing
442
+ # (so arrow keys work)
443
+ if self.command_mode != CommandMode.NORMAL:
444
+ logger.info(
445
+ f"🎯 Processing key '{key_press.name}' "
446
+ f"in command mode: {self.command_mode}"
447
+ )
448
+ handled = await self._handle_command_mode_keypress(key_press)
449
+ if handled:
450
+ return
451
+
452
+ # Emit key press event for plugins
453
+ key_result = await self.event_bus.emit_with_hooks(
454
+ EventType.KEY_PRESS,
455
+ {
456
+ "key": key_press.name,
457
+ "char_code": key_press.code,
458
+ "key_type": key_press.type.value,
459
+ "modifiers": key_press.modifiers,
460
+ },
461
+ "input",
462
+ )
463
+
464
+ # Check if any plugin handled this key
465
+ prevent_default = self._check_prevent_default(key_result)
466
+
467
+ # Process key if not prevented by plugins
468
+ if not prevent_default:
469
+ await self._handle_key_press(key_press)
470
+
471
+ # Update renderer
472
+ await self._update_display()
473
+
474
+ except Exception as e:
475
+ await self.error_handler.handle_error(
476
+ ErrorType.PARSING_ERROR,
477
+ f"Error processing character: {e}",
478
+ ErrorSeverity.MEDIUM,
479
+ {"char": repr(char), "buffer_manager": self.buffer_manager},
480
+ )
481
+
482
+ def _check_prevent_default(self, key_result: Dict[str, Any]) -> bool:
483
+ """Check if plugins want to prevent default key handling.
484
+
485
+ Args:
486
+ key_result: Result from key press event.
487
+
488
+ Returns:
489
+ True if default handling should be prevented.
490
+ """
491
+ if "main" in key_result:
492
+ for hook_result in key_result["main"].values():
493
+ if isinstance(hook_result, dict) and hook_result.get(
494
+ "prevent_default"
495
+ ):
496
+ return True
497
+ return False
498
+
499
+ async def _handle_key_press(self, key_press: KeyPress) -> None:
500
+ """Handle a parsed key press.
501
+
502
+ Args:
503
+ key_press: Parsed key press event.
504
+ """
505
+ # Process key press
506
+ try:
507
+ # Log all key presses for debugging
508
+ logger.info(
509
+ f"🔍 Key press: name='{key_press.name}', "
510
+ f"char='{key_press.char}', code={key_press.code}, "
511
+ f"type={key_press.type}, "
512
+ f"modifiers={getattr(key_press, 'modifiers', None)}"
513
+ )
514
+
515
+ # CRITICAL FIX: Modal input isolation
516
+ # capture ALL input when in modal mode
517
+ if self.command_mode == CommandMode.MODAL:
518
+ logger.info(
519
+ f"🎯 Modal mode active - routing ALL input "
520
+ f"to modal handler: {key_press.name}"
521
+ )
522
+ await self._handle_command_mode_keypress(key_press)
523
+ return
524
+
525
+ # Handle control keys
526
+ if self.key_parser.is_control_key(key_press, "Ctrl+C"):
527
+ logger.info("Ctrl+C received")
528
+ raise KeyboardInterrupt
529
+
530
+ elif self.key_parser.is_control_key(key_press, "Enter"):
531
+ await self._handle_enter()
532
+
533
+ elif self.key_parser.is_control_key(key_press, "Backspace"):
534
+ self.buffer_manager.delete_char()
535
+
536
+ elif key_press.name == "Escape":
537
+ await self._handle_escape()
538
+
539
+ elif key_press.name == "Delete":
540
+ self.buffer_manager.delete_forward()
541
+
542
+ # Handle arrow keys for cursor movement and history
543
+ elif key_press.name == "ArrowLeft":
544
+ moved = self.buffer_manager.move_cursor("left")
545
+ if moved:
546
+ logger.debug(
547
+ f"Arrow Left: cursor moved to position {self.buffer_manager.cursor_position}"
548
+ )
549
+ await self._update_display(force_render=True)
550
+
551
+ elif key_press.name == "ArrowRight":
552
+ moved = self.buffer_manager.move_cursor("right")
553
+ if moved:
554
+ logger.debug(
555
+ f"Arrow Right: cursor moved to position {self.buffer_manager.cursor_position}"
556
+ )
557
+ await self._update_display(force_render=True)
558
+
559
+ elif key_press.name == "ArrowUp":
560
+ self.buffer_manager.navigate_history("up")
561
+ await self._update_display(force_render=True)
562
+
563
+ elif key_press.name == "ArrowDown":
564
+ self.buffer_manager.navigate_history("down")
565
+ await self._update_display(force_render=True)
566
+
567
+ # Handle Home/End keys
568
+ elif key_press.name == "Home":
569
+ self.buffer_manager.move_to_start()
570
+ await self._update_display(force_render=True)
571
+
572
+ elif key_press.name == "End":
573
+ self.buffer_manager.move_to_end()
574
+ await self._update_display(force_render=True)
575
+
576
+ # Handle Option/Alt+comma/period keys for status view navigation
577
+ # macOS sends ≤/≥, Windows/Linux send Alt modifier or ESC+key
578
+ elif (key_press.char == "≤" or
579
+ key_press.name == "Alt+," or
580
+ (key_press.char == "," and key_press.modifiers.get("alt"))):
581
+ logger.info(
582
+ "🔑 Alt+Comma detected - switching to previous status view"
583
+ )
584
+ await self._handle_status_view_previous()
585
+
586
+ elif (key_press.char == "≥" or
587
+ key_press.name == "Alt+." or
588
+ (key_press.char == "." and key_press.modifiers.get("alt"))):
589
+ logger.info(
590
+ "🔑 Alt+Period detected - switching to next status view"
591
+ )
592
+ await self._handle_status_view_next()
593
+
594
+ # Handle Cmd key combinations (mapped to Ctrl sequences on macOS)
595
+ elif self.key_parser.is_control_key(key_press, "Ctrl+A"):
596
+ logger.info("🔑 Ctrl+A (Cmd+Left) - moving cursor to start")
597
+ self.buffer_manager.move_to_start()
598
+ await self._update_display(force_render=True)
599
+
600
+ elif self.key_parser.is_control_key(key_press, "Ctrl+E"):
601
+ logger.info("🔑 Ctrl+E (Cmd+Right) - moving cursor to end")
602
+ self.buffer_manager.move_to_end()
603
+ await self._update_display(force_render=True)
604
+
605
+ elif self.key_parser.is_control_key(key_press, "Ctrl+U"):
606
+ logger.info("🔑 Ctrl+U (Cmd+Backspace) - clearing line")
607
+ self.buffer_manager.clear()
608
+ await self._update_display(force_render=True)
609
+
610
+ # Handle printable characters
611
+ elif self.key_parser.is_printable_char(key_press):
612
+ # Normal character processing
613
+ success = self.buffer_manager.insert_char(key_press.char)
614
+ if not success:
615
+ await self.error_handler.handle_error(
616
+ ErrorType.BUFFER_ERROR,
617
+ "Failed to insert character - buffer limit reached",
618
+ ErrorSeverity.LOW,
619
+ {
620
+ "char": key_press.char,
621
+ "buffer_manager": self.buffer_manager,
622
+ },
623
+ )
624
+
625
+ # Handle other special keys (F1-F12, etc.)
626
+ elif key_press.type == KeyTypeEnum.EXTENDED:
627
+ logger.debug(f"Extended key pressed: {key_press.name}")
628
+ # Could emit special events for function keys, etc.
629
+
630
+ except Exception as e:
631
+ await self.error_handler.handle_error(
632
+ ErrorType.EVENT_ERROR,
633
+ f"Error handling key press: {e}",
634
+ ErrorSeverity.MEDIUM,
635
+ {
636
+ "key_press": key_press,
637
+ "buffer_manager": self.buffer_manager,
638
+ },
639
+ )
640
+
641
+ async def _update_display(self, force_render: bool = False) -> None:
642
+ """Update the terminal display with current buffer state."""
643
+ try:
644
+ # Skip rendering if paused (during special effects like Matrix)
645
+ if self.rendering_paused and not force_render:
646
+ return
647
+
648
+ buffer_content, cursor_pos = self.buffer_manager.get_display_info()
649
+
650
+ # Update renderer with buffer content and cursor position
651
+ self.renderer.input_buffer = buffer_content
652
+ self.renderer.cursor_position = cursor_pos
653
+
654
+ # Force immediate rendering if requested (needed for paste operations)
655
+ if force_render:
656
+ try:
657
+ if hasattr(
658
+ self.renderer, "render_active_area"
659
+ ) and asyncio.iscoroutinefunction(
660
+ self.renderer.render_active_area
661
+ ):
662
+ await self.renderer.render_active_area()
663
+ elif hasattr(
664
+ self.renderer, "render_input"
665
+ ) and asyncio.iscoroutinefunction(self.renderer.render_input):
666
+ await self.renderer.render_input()
667
+ elif hasattr(self.renderer, "render_active_area"):
668
+ self.renderer.render_active_area()
669
+ elif hasattr(self.renderer, "render_input"):
670
+ self.renderer.render_input()
671
+ except Exception as e:
672
+ logger.debug(f"Force render failed: {e}")
673
+ # Continue without forced render
674
+
675
+ # Only update cursor if position changed
676
+ if cursor_pos != self._last_cursor_pos:
677
+ # Could implement cursor positioning in renderer
678
+ self._last_cursor_pos = cursor_pos
679
+
680
+ except Exception as e:
681
+ await self.error_handler.handle_error(
682
+ ErrorType.SYSTEM_ERROR,
683
+ f"Error updating display: {e}",
684
+ ErrorSeverity.LOW,
685
+ {"buffer_manager": self.buffer_manager},
686
+ )
687
+
688
+ def pause_rendering(self):
689
+ """Pause all UI rendering for special effects."""
690
+ self.rendering_paused = True
691
+ logger.debug("Input rendering paused")
692
+
693
+ def resume_rendering(self):
694
+ """Resume normal UI rendering."""
695
+ self.rendering_paused = False
696
+ logger.debug("Input rendering resumed")
697
+
698
+ async def _handle_enter(self) -> None:
699
+ """Handle Enter key press with enhanced validation."""
700
+ try:
701
+ if self.buffer_manager.is_empty:
702
+ return
703
+
704
+ # Validate input before processing
705
+ validation_errors = self.buffer_manager.validate_content()
706
+ if validation_errors:
707
+ for error in validation_errors:
708
+ logger.warning(f"Input validation warning: {error}")
709
+
710
+ # Get message and clear buffer
711
+ message = self.buffer_manager.get_content_and_clear()
712
+
713
+ # Check if this is a slash command - handle immediately without paste expansion
714
+ if message.strip().startswith('/'):
715
+ logger.info(f"🎯 Detected slash command, bypassing paste expansion: '{message}'")
716
+ expanded_message = message
717
+ else:
718
+ # GENIUS PASTE BUCKET: Immediate expansion - no waiting needed!
719
+ logger.debug(f"GENIUS SUBMIT: Original message: '{message}'")
720
+ logger.debug(
721
+ f"GENIUS SUBMIT: Paste bucket contains: {list(self._paste_bucket.keys())}"
722
+ )
723
+
724
+ expanded_message = self._expand_paste_placeholders(message)
725
+ logger.debug(
726
+ f"GENIUS SUBMIT: Final expanded: '{expanded_message[:100]}...' ({len(expanded_message)} chars)"
727
+ )
728
+
729
+ # Add to history (with expanded content)
730
+ self.buffer_manager.add_to_history(expanded_message)
731
+
732
+ # Update renderer
733
+ self.renderer.input_buffer = ""
734
+ self.renderer.clear_active_area()
735
+
736
+ # Emit user input event (with expanded content!)
737
+ await self.event_bus.emit_with_hooks(
738
+ EventType.USER_INPUT,
739
+ {
740
+ "message": expanded_message,
741
+ "validation_errors": validation_errors,
742
+ },
743
+ "user",
744
+ )
745
+
746
+ logger.debug(
747
+ f"Processed user input: {message[:100]}..."
748
+ if len(message) > 100
749
+ else f"Processed user input: {message}"
750
+ )
751
+
752
+ except Exception as e:
753
+ await self.error_handler.handle_error(
754
+ ErrorType.EVENT_ERROR,
755
+ f"Error handling Enter key: {e}",
756
+ ErrorSeverity.HIGH,
757
+ {"buffer_manager": self.buffer_manager},
758
+ )
759
+
760
+ async def _handle_escape(self) -> None:
761
+ """Handle Escape key press for request cancellation."""
762
+ try:
763
+ logger.info("_handle_escape called - emitting CANCEL_REQUEST event")
764
+
765
+ # Emit cancellation event
766
+ result = await self.event_bus.emit_with_hooks(
767
+ EventType.CANCEL_REQUEST,
768
+ {"reason": "user_escape", "source": "input_handler"},
769
+ "input",
770
+ )
771
+
772
+ logger.info(
773
+ f"ESC key pressed - cancellation request sent, result: {result}"
774
+ )
775
+
776
+ except Exception as e:
777
+ await self.error_handler.handle_error(
778
+ ErrorType.EVENT_ERROR,
779
+ f"Error handling Escape key: {e}",
780
+ ErrorSeverity.MEDIUM,
781
+ {"buffer_manager": self.buffer_manager},
782
+ )
783
+
784
+ async def _handle_status_view_previous(self) -> None:
785
+ """Handle comma key press for previous status view."""
786
+ try:
787
+ logger.info("Attempting to switch to previous status view")
788
+ # Check if renderer has a status registry
789
+ if (
790
+ hasattr(self.renderer, "status_renderer")
791
+ and self.renderer.status_renderer
792
+ ):
793
+ status_renderer = self.renderer.status_renderer
794
+ logger.info(
795
+ f"[OK] Found status_renderer: {type(status_renderer).__name__}"
796
+ )
797
+ if (
798
+ hasattr(status_renderer, "status_registry")
799
+ and status_renderer.status_registry
800
+ ):
801
+ registry = status_renderer.status_registry
802
+ logger.info(
803
+ f"[OK] Found status_registry with {len(registry.views)} views"
804
+ )
805
+ if hasattr(registry, "cycle_previous"):
806
+ previous_view = registry.cycle_previous()
807
+ if previous_view:
808
+ logger.info(
809
+ f"[OK] Switched to previous status view: '{previous_view.name}'"
810
+ )
811
+ else:
812
+ logger.info("No status views available to cycle to")
813
+ else:
814
+ logger.info("cycle_previous method not found in registry")
815
+ else:
816
+ logger.info("No status registry available for view cycling")
817
+ else:
818
+ logger.info("No status renderer available for view cycling")
819
+
820
+ except Exception as e:
821
+ await self.error_handler.handle_error(
822
+ ErrorType.EVENT_ERROR,
823
+ f"Error handling status view previous: {e}",
824
+ ErrorSeverity.LOW,
825
+ {"key": "Ctrl+ArrowLeft"},
826
+ )
827
+
828
+ async def _handle_status_view_next(self) -> None:
829
+ """Handle Ctrl+Right arrow key press for next status view."""
830
+ try:
831
+ # Check if renderer has a status registry
832
+ if (
833
+ hasattr(self.renderer, "status_renderer")
834
+ and self.renderer.status_renderer
835
+ ):
836
+ status_renderer = self.renderer.status_renderer
837
+ if (
838
+ hasattr(status_renderer, "status_registry")
839
+ and status_renderer.status_registry
840
+ ):
841
+ next_view = status_renderer.status_registry.cycle_next()
842
+ if next_view:
843
+ logger.debug(
844
+ f"Switched to next status view: '{next_view.name}'"
845
+ )
846
+ else:
847
+ logger.debug("No status views available to cycle to")
848
+ else:
849
+ logger.debug("No status registry available for view cycling")
850
+ else:
851
+ logger.debug("No status renderer available for view cycling")
852
+
853
+ except Exception as e:
854
+ await self.error_handler.handle_error(
855
+ ErrorType.EVENT_ERROR,
856
+ f"Error handling status view next: {e}",
857
+ ErrorSeverity.LOW,
858
+ {"key": "Ctrl+ArrowRight"},
859
+ )
860
+
861
+ def get_status(self) -> Dict[str, Any]:
862
+ """Get current input handler status for debugging.
863
+
864
+ Returns:
865
+ Dictionary containing status information.
866
+ """
867
+ buffer_stats = self.buffer_manager.get_stats()
868
+ error_stats = self.error_handler.get_error_stats()
869
+
870
+ return {
871
+ "running": self.running,
872
+ "buffer": buffer_stats,
873
+ "errors": error_stats,
874
+ "parser_state": {
875
+ "in_escape_sequence": self.key_parser._in_escape_sequence,
876
+ "escape_buffer": self.key_parser._escape_buffer,
877
+ },
878
+ }
879
+
880
+ async def cleanup(self) -> None:
881
+ """Perform cleanup operations."""
882
+ try:
883
+ # Clear old errors
884
+ cleared_errors = self.error_handler.clear_old_errors()
885
+ if cleared_errors > 0:
886
+ logger.info(f"Cleaned up {cleared_errors} old errors")
887
+
888
+ # Reset parser state
889
+ self.key_parser._reset_escape_state()
890
+
891
+ logger.debug("Input handler cleanup completed")
892
+
893
+ except Exception as e:
894
+ logger.error(f"Error during cleanup: {e}")
895
+
896
+ def _expand_paste_placeholders(self, message: str) -> str:
897
+ """Expand paste placeholders with actual content from paste bucket.
898
+
899
+ Your brilliant idea: Replace [⚡ Pasted #N ...] with actual pasted content!
900
+ """
901
+ logger.debug(f"PASTE DEBUG: Expanding message: '{message}'")
902
+ logger.debug(
903
+ f"PASTE DEBUG: Paste bucket contains: {list(self._paste_bucket.keys())}"
904
+ )
905
+
906
+ expanded = message
907
+
908
+ # Find and replace each paste placeholder
909
+ import re
910
+
911
+ for paste_id, content in self._paste_bucket.items():
912
+ # Extract paste number from paste_id (PASTE_1 -> 1)
913
+ paste_num = paste_id.split("_")[1]
914
+
915
+ # Pattern to match: [Pasted #N X lines, Y chars]
916
+ pattern = rf"\[Pasted #{paste_num} \d+ lines?, \d+ chars\]"
917
+
918
+ logger.debug(f"PASTE DEBUG: Looking for pattern: {pattern}")
919
+ logger.debug(
920
+ f"PASTE DEBUG: Will replace with content: '{content[:50]}...'"
921
+ )
922
+
923
+ # Replace with actual content
924
+ matches = re.findall(pattern, expanded)
925
+ logger.debug(f"PASTE DEBUG: Found {len(matches)} matches")
926
+
927
+ expanded = re.sub(pattern, content, expanded)
928
+
929
+ logger.debug(f"PASTE DEBUG: Final expanded message: '{expanded[:100]}...'")
930
+ logger.info(
931
+ f"Paste expansion: {len(self._paste_bucket)} placeholders expanded"
932
+ )
933
+
934
+ # Clear paste bucket after expansion (one-time use)
935
+ self._paste_bucket.clear()
936
+
937
+ return expanded
938
+
939
+ async def _create_paste_placeholder(self, paste_id: str) -> None:
940
+ """Create placeholder for paste - GENIUS IMMEDIATE VERSION."""
941
+ content = self._paste_bucket[paste_id]
942
+
943
+ # Create elegant placeholder for user to see
944
+ line_count = content.count("\n") + 1 if "\n" in content else 1
945
+ char_count = len(content)
946
+ paste_num = paste_id.split("_")[1] # Extract number from PASTE_1
947
+ placeholder = f"[Pasted #{paste_num} {line_count} lines, {char_count} chars]"
948
+
949
+ # Insert placeholder into buffer (what user sees)
950
+ for char in placeholder:
951
+ self.buffer_manager.insert_char(char)
952
+
953
+ logger.info(
954
+ f"GENIUS: Created placeholder for {char_count} chars as {paste_id}"
955
+ )
956
+
957
+ # Update display once at the end
958
+ await self._update_display(force_render=True)
959
+
960
+ async def _update_paste_placeholder(self) -> None:
961
+ """Update existing placeholder when paste grows - GENIUS VERSION."""
962
+ # For now, just log - updating existing placeholder is complex
963
+ # The merge approach usually works fast enough that this isn't needed
964
+ content = self._paste_bucket[self._current_paste_id]
965
+ logger.info(
966
+ f"GENIUS: Updated {self._current_paste_id} to {len(content)} chars"
967
+ )
968
+
969
+ async def _simple_paste_detection(self, char: str, current_time: float) -> bool:
970
+ """Simple, reliable paste detection using timing only.
971
+
972
+ Returns:
973
+ True if character was consumed by paste detection, False otherwise.
974
+ """
975
+ # Check cooldown to prevent overlapping paste detections
976
+ if self._paste_cooldown > 0 and (current_time - self._paste_cooldown) < 1.0:
977
+ # Still in cooldown period, skip paste detection
978
+ self._last_char_time = current_time
979
+ return False
980
+
981
+ # Check if we have a pending paste buffer that timed out
982
+ if self._paste_buffer and self._last_char_time > 0:
983
+ gap_ms = (current_time - self._last_char_time) * 1000
984
+
985
+ if gap_ms > self._paste_timeout_ms:
986
+ # Buffer timed out, process it
987
+ if len(self._paste_buffer) >= self.paste_min_chars:
988
+ self._process_simple_paste_sync()
989
+ self._paste_cooldown = current_time # Set cooldown
990
+ else:
991
+ # Too few chars, process them as individual keystrokes
992
+ self._flush_paste_buffer_as_keystrokes_sync()
993
+ self._paste_buffer = []
994
+
995
+ # Now handle the current character
996
+ if self._last_char_time > 0:
997
+ gap_ms = (current_time - self._last_char_time) * 1000
998
+
999
+ # If character arrived quickly, start/continue paste buffer
1000
+ if gap_ms < self.paste_threshold_ms:
1001
+ self._paste_buffer.append(char)
1002
+ self._last_char_time = current_time
1003
+ return True # Character consumed by paste buffer
1004
+
1005
+ # Character not part of paste, process normally
1006
+ self._last_char_time = current_time
1007
+ return False
1008
+
1009
+ def _flush_paste_buffer_as_keystrokes_sync(self) -> None:
1010
+ """Process paste buffer contents as individual keystrokes (sync version)."""
1011
+ logger.debug(
1012
+ f"Flushing {len(self._paste_buffer)} chars as individual keystrokes"
1013
+ )
1014
+
1015
+ # Just add characters to buffer without async processing
1016
+ for char in self._paste_buffer:
1017
+ if char.isprintable() or char in [" ", "\t"]:
1018
+ self.buffer_manager.insert_char(char)
1019
+
1020
+ def _process_simple_paste_sync(self) -> None:
1021
+ """Process detected paste content (sync version with inline indicator)."""
1022
+ if not self._paste_buffer:
1023
+ return
1024
+
1025
+ # Get the content and clean any terminal markers
1026
+ content = "".join(self._paste_buffer)
1027
+
1028
+ # Clean bracketed paste markers if present
1029
+ if content.startswith("[200~"):
1030
+ content = content[5:]
1031
+ if content.endswith("01~"):
1032
+ content = content[:-3]
1033
+ elif content.endswith("[201~"):
1034
+ content = content[:-6]
1035
+
1036
+ # Count lines
1037
+ line_count = content.count("\n") + 1
1038
+ char_count = len(content)
1039
+
1040
+ # Increment paste counter
1041
+ self._paste_counter += 1
1042
+
1043
+ # Create inline paste indicator exactly as user requested
1044
+ indicator = f"[⚡ Pasted #{self._paste_counter} {line_count} lines]"
1045
+
1046
+ # Insert the indicator into the buffer at current position
1047
+ try:
1048
+ for char in indicator:
1049
+ self.buffer_manager.insert_char(char)
1050
+ logger.info(
1051
+ f"Paste #{self._paste_counter}: {char_count} chars, {line_count} lines"
1052
+ )
1053
+ except Exception as e:
1054
+ logger.error(f"Paste processing error: {e}")
1055
+
1056
+ # Clear paste buffer
1057
+ self._paste_buffer = []
1058
+
1059
+ async def _flush_paste_buffer_as_keystrokes(self) -> None:
1060
+ """Process paste buffer contents as individual keystrokes."""
1061
+ self._flush_paste_buffer_as_keystrokes_sync()
1062
+
1063
+ async def _process_simple_paste(self) -> None:
1064
+ """Process detected paste content."""
1065
+ self._process_simple_paste_sync()
1066
+ await self._update_display(force_render=True)
1067
+
1068
+ # ==================== COMMAND MENU RENDER HOOK ====================
1069
+
1070
+ async def _register_command_menu_render_hook(self) -> None:
1071
+ """Register hook to provide command menu content for COMMAND_MENU_RENDER events."""
1072
+ try:
1073
+ if self.event_bus:
1074
+ from ..events.models import Hook, HookPriority
1075
+
1076
+ hook = Hook(
1077
+ name="command_menu_render",
1078
+ plugin_name="input_handler",
1079
+ event_type=EventType.COMMAND_MENU_RENDER,
1080
+ priority=HookPriority.DISPLAY.value,
1081
+ callback=self._handle_command_menu_render,
1082
+ )
1083
+ success = await self.event_bus.register_hook(hook)
1084
+ if success:
1085
+ logger.info(
1086
+ "Successfully registered COMMAND_MENU_RENDER hook for command menu display"
1087
+ )
1088
+ else:
1089
+ logger.error("Failed to register COMMAND_MENU_RENDER hook")
1090
+ except Exception as e:
1091
+ logger.error(f"Failed to register COMMAND_MENU_RENDER hook: {e}")
1092
+
1093
+ async def _handle_command_menu_render(
1094
+ self, event_data: Dict[str, Any], context: str = None
1095
+ ) -> Dict[str, Any]:
1096
+ """Handle COMMAND_MENU_RENDER events to provide command menu content.
1097
+
1098
+ Args:
1099
+ event_data: Event data containing render request info.
1100
+
1101
+ Returns:
1102
+ Dictionary with menu_lines if command mode is active.
1103
+ """
1104
+ try:
1105
+ # Only provide command menu if we're in menu popup mode
1106
+ if (
1107
+ self.command_mode == CommandMode.MENU_POPUP
1108
+ and self.command_menu_active
1109
+ and hasattr(self.command_menu_renderer, "current_menu_lines")
1110
+ and self.command_menu_renderer.current_menu_lines
1111
+ ):
1112
+
1113
+ return {"menu_lines": self.command_menu_renderer.current_menu_lines}
1114
+
1115
+ # No command menu to display
1116
+ return {}
1117
+
1118
+ except Exception as e:
1119
+ logger.error(f"Error in COMMAND_MENU_RENDER handler: {e}")
1120
+ return {}
1121
+
1122
+ async def _register_modal_trigger_hook(self) -> None:
1123
+ """Register hook to handle modal trigger events."""
1124
+ try:
1125
+ if self.event_bus:
1126
+ from ..events.models import Hook, HookPriority, EventType
1127
+
1128
+ hook = Hook(
1129
+ name="modal_trigger",
1130
+ plugin_name="input_handler",
1131
+ event_type=EventType.MODAL_TRIGGER,
1132
+ priority=HookPriority.DISPLAY.value,
1133
+ callback=self._handle_modal_trigger,
1134
+ )
1135
+ success = await self.event_bus.register_hook(hook)
1136
+ if success:
1137
+ logger.info("Successfully registered MODAL_TRIGGER hook")
1138
+ else:
1139
+ logger.error("Failed to register MODAL_TRIGGER hook")
1140
+ except Exception as e:
1141
+ logger.error(f"Failed to register MODAL_TRIGGER hook: {e}")
1142
+
1143
+ async def _register_status_modal_trigger_hook(self) -> None:
1144
+ """Register hook to handle status modal trigger events."""
1145
+ try:
1146
+ if self.event_bus:
1147
+ from ..events.models import Hook, HookPriority, EventType
1148
+
1149
+ hook = Hook(
1150
+ name="status_modal_trigger",
1151
+ plugin_name="input_handler",
1152
+ event_type=EventType.STATUS_MODAL_TRIGGER,
1153
+ priority=HookPriority.DISPLAY.value,
1154
+ callback=self._handle_status_modal_trigger,
1155
+ )
1156
+ success = await self.event_bus.register_hook(hook)
1157
+ if success:
1158
+ logger.info("Successfully registered STATUS_MODAL_TRIGGER hook")
1159
+ else:
1160
+ logger.error("Failed to register STATUS_MODAL_TRIGGER hook")
1161
+ except Exception as e:
1162
+ logger.error(f"Failed to register STATUS_MODAL_TRIGGER hook: {e}")
1163
+
1164
+ async def _register_live_modal_trigger_hook(self) -> None:
1165
+ """Register hook to handle live modal trigger events."""
1166
+ try:
1167
+ if self.event_bus:
1168
+ from ..events.models import Hook, HookPriority, EventType
1169
+
1170
+ hook = Hook(
1171
+ name="live_modal_trigger",
1172
+ plugin_name="input_handler",
1173
+ event_type=EventType.LIVE_MODAL_TRIGGER,
1174
+ priority=HookPriority.DISPLAY.value,
1175
+ callback=self._handle_live_modal_trigger,
1176
+ )
1177
+ success = await self.event_bus.register_hook(hook)
1178
+ if success:
1179
+ logger.info("Successfully registered LIVE_MODAL_TRIGGER hook")
1180
+ else:
1181
+ logger.error("Failed to register LIVE_MODAL_TRIGGER hook")
1182
+ except Exception as e:
1183
+ logger.error(f"Failed to register LIVE_MODAL_TRIGGER hook: {e}")
1184
+
1185
+ async def _handle_live_modal_trigger(
1186
+ self, event_data: Dict[str, Any], context: str = None
1187
+ ) -> Dict[str, Any]:
1188
+ """Handle live modal trigger events to show live modals.
1189
+
1190
+ Args:
1191
+ event_data: Event data containing content_generator, config, input_callback.
1192
+ context: Hook execution context.
1193
+
1194
+ Returns:
1195
+ Dictionary with live modal result.
1196
+ """
1197
+ try:
1198
+ content_generator = event_data.get("content_generator")
1199
+ config = event_data.get("config")
1200
+ input_callback = event_data.get("input_callback")
1201
+
1202
+ if content_generator:
1203
+ logger.info(f"Live modal trigger received: {config.title if config else 'untitled'}")
1204
+ # Enter live modal mode (this will block until modal closes)
1205
+ result = await self.enter_live_modal_mode(
1206
+ content_generator,
1207
+ config,
1208
+ input_callback
1209
+ )
1210
+ return {"success": True, "live_modal_activated": True, "result": result}
1211
+ else:
1212
+ logger.warning("Live modal trigger received without content_generator")
1213
+ return {"success": False, "error": "Missing content_generator"}
1214
+ except Exception as e:
1215
+ logger.error(f"Error handling live modal trigger: {e}")
1216
+ return {"success": False, "error": str(e)}
1217
+
1218
+ async def _register_status_modal_render_hook(self) -> None:
1219
+ """Register hook to handle status modal render events."""
1220
+ try:
1221
+ if self.event_bus:
1222
+ from ..events.models import Hook, HookPriority, EventType
1223
+
1224
+ hook = Hook(
1225
+ name="status_modal_render",
1226
+ plugin_name="input_handler",
1227
+ event_type=EventType.STATUS_MODAL_RENDER,
1228
+ priority=HookPriority.DISPLAY.value,
1229
+ callback=self._handle_status_modal_render,
1230
+ )
1231
+ success = await self.event_bus.register_hook(hook)
1232
+ if success:
1233
+ logger.info("Successfully registered STATUS_MODAL_RENDER hook")
1234
+ else:
1235
+ logger.error("Failed to register STATUS_MODAL_RENDER hook")
1236
+ except Exception as e:
1237
+ logger.error(f"Failed to register STATUS_MODAL_RENDER hook: {e}")
1238
+
1239
+ async def _register_command_output_display_hook(self) -> None:
1240
+ """Register hook to handle command output display events."""
1241
+ try:
1242
+ if self.event_bus:
1243
+ from ..events.models import Hook, HookPriority, EventType
1244
+
1245
+ hook = Hook(
1246
+ name="command_output_display",
1247
+ plugin_name="input_handler",
1248
+ event_type=EventType.COMMAND_OUTPUT_DISPLAY,
1249
+ priority=HookPriority.DISPLAY.value,
1250
+ callback=self._handle_command_output_display,
1251
+ )
1252
+ success = await self.event_bus.register_hook(hook)
1253
+ if success:
1254
+ logger.info(
1255
+ "Successfully registered COMMAND_OUTPUT_DISPLAY hook"
1256
+ )
1257
+ else:
1258
+ logger.error("Failed to register COMMAND_OUTPUT_DISPLAY hook")
1259
+ except Exception as e:
1260
+ logger.error(f"Failed to register COMMAND_OUTPUT_DISPLAY hook: {e}")
1261
+
1262
+ # Register pause/resume rendering hooks
1263
+ await self._register_pause_rendering_hook()
1264
+ await self._register_resume_rendering_hook()
1265
+
1266
+ # Register modal hide hook for Matrix effect cleanup
1267
+ await self._register_modal_hide_hook()
1268
+
1269
+ async def _register_pause_rendering_hook(self) -> None:
1270
+ """Register hook for pause rendering events."""
1271
+ try:
1272
+ if self.event_bus:
1273
+ from ..events.models import Hook, HookPriority, EventType
1274
+
1275
+ hook = Hook(
1276
+ name="pause_rendering",
1277
+ plugin_name="input_handler",
1278
+ event_type=EventType.PAUSE_RENDERING,
1279
+ priority=HookPriority.DISPLAY.value,
1280
+ callback=self._handle_pause_rendering,
1281
+ )
1282
+ success = await self.event_bus.register_hook(hook)
1283
+ if success:
1284
+ logger.info("Successfully registered PAUSE_RENDERING hook")
1285
+ else:
1286
+ logger.error("Failed to register PAUSE_RENDERING hook")
1287
+ except Exception as e:
1288
+ logger.error(f"Error registering PAUSE_RENDERING hook: {e}")
1289
+
1290
+ async def _register_resume_rendering_hook(self) -> None:
1291
+ """Register hook for resume rendering events."""
1292
+ try:
1293
+ if self.event_bus:
1294
+ from ..events.models import Hook, HookPriority, EventType
1295
+
1296
+ hook = Hook(
1297
+ name="resume_rendering",
1298
+ plugin_name="input_handler",
1299
+ event_type=EventType.RESUME_RENDERING,
1300
+ priority=HookPriority.DISPLAY.value,
1301
+ callback=self._handle_resume_rendering,
1302
+ )
1303
+ success = await self.event_bus.register_hook(hook)
1304
+ if success:
1305
+ logger.info("Successfully registered RESUME_RENDERING hook")
1306
+ else:
1307
+ logger.error("Failed to register RESUME_RENDERING hook")
1308
+ except Exception as e:
1309
+ logger.error(f"Error registering RESUME_RENDERING hook: {e}")
1310
+
1311
+ async def _handle_pause_rendering(
1312
+ self, event_data: Dict[str, Any], context: str = None
1313
+ ) -> Dict[str, Any]:
1314
+ """Handle pause rendering event."""
1315
+ logger.info("🛑 PAUSE_RENDERING event received - pausing input rendering")
1316
+ self.rendering_paused = True
1317
+ return {"status": "paused"}
1318
+
1319
+ async def _handle_resume_rendering(
1320
+ self, event_data: Dict[str, Any], context: str = None
1321
+ ) -> Dict[str, Any]:
1322
+ """Handle resume rendering event."""
1323
+ logger.info("▶️ RESUME_RENDERING event received - resuming input rendering")
1324
+ self.rendering_paused = False
1325
+ # Force a refresh when resuming
1326
+ await self._update_display(force_render=True)
1327
+ return {"status": "resumed"}
1328
+
1329
+ async def _register_modal_hide_hook(self) -> None:
1330
+ """Register hook for modal hide events."""
1331
+ try:
1332
+ if self.event_bus:
1333
+ from ..events.models import Hook, HookPriority, EventType
1334
+
1335
+ hook = Hook(
1336
+ name="modal_hide",
1337
+ plugin_name="input_handler",
1338
+ event_type=EventType.MODAL_HIDE,
1339
+ priority=HookPriority.DISPLAY.value,
1340
+ callback=self._handle_modal_hide,
1341
+ )
1342
+ success = await self.event_bus.register_hook(hook)
1343
+ if success:
1344
+ logger.info("Successfully registered MODAL_HIDE hook")
1345
+ else:
1346
+ logger.error("Failed to register MODAL_HIDE hook")
1347
+ except Exception as e:
1348
+ logger.error(f"Error registering MODAL_HIDE hook: {e}")
1349
+
1350
+ async def _handle_modal_hide(
1351
+ self, event_data: Dict[str, Any], context: str = None
1352
+ ) -> Dict[str, Any]:
1353
+ """Handle modal hide event to exit modal mode."""
1354
+ logger.info("🔄 MODAL_HIDE event received - exiting modal mode")
1355
+ try:
1356
+ from ..events.models import CommandMode
1357
+
1358
+ # CRITICAL FIX: Clear input area before restoring (like config modal does)
1359
+ self.renderer.clear_active_area()
1360
+ self.renderer.writing_messages = False
1361
+
1362
+ self.command_mode = CommandMode.NORMAL
1363
+ # CRITICAL FIX: Clear fullscreen session flag when exiting modal
1364
+ if hasattr(self, "_fullscreen_session_active"):
1365
+ self._fullscreen_session_active = False
1366
+ logger.info("🔄 Fullscreen session marked as inactive")
1367
+ logger.info("🔄 Command mode reset to NORMAL after modal hide")
1368
+
1369
+ # Force refresh of display when exiting modal mode
1370
+ await self._update_display(force_render=True)
1371
+ return {"success": True, "modal_deactivated": True}
1372
+ except Exception as e:
1373
+ logger.error(f"Error handling modal hide: {e}")
1374
+ return {"success": False, "error": str(e)}
1375
+
1376
+ async def _handle_command_output_display(
1377
+ self, event_data: Dict[str, Any], context: str = None
1378
+ ) -> Dict[str, Any]:
1379
+ """Handle command output display events.
1380
+
1381
+ Args:
1382
+ event_data: Event data containing command output information.
1383
+ context: Hook execution context.
1384
+
1385
+ Returns:
1386
+ Dictionary with display result.
1387
+ """
1388
+ try:
1389
+ message = event_data.get("message", "")
1390
+ display_type = event_data.get("display_type", "info")
1391
+ _ = event_data.get("success", True)
1392
+
1393
+ if message:
1394
+ # Format message based on display type
1395
+ if display_type == "error":
1396
+ formatted_message = f"❌ {message}"
1397
+ elif display_type == "warning":
1398
+ formatted_message = f"⚠️ {message}"
1399
+ elif display_type == "success":
1400
+ formatted_message = f"✅ {message}"
1401
+ else: # info or default
1402
+ formatted_message = f"ℹ️ {message}"
1403
+
1404
+ # FIXED: Remove writing_messages management here - message_coordinator handles it
1405
+ # The message_coordinator.display_message_sequence() properly manages the flag
1406
+
1407
+ # Clear the active input area first
1408
+ self.renderer.clear_active_area()
1409
+
1410
+ # CRITICAL FIX: Use write_hook_message to avoid ∴ prefix on command output
1411
+ # This routes through message_coordinator which handles writing_messages flag properly
1412
+ self.renderer.write_hook_message(
1413
+ formatted_message,
1414
+ display_type=display_type,
1415
+ source="command",
1416
+ )
1417
+
1418
+ # Force a display update to ensure message appears
1419
+ await self._update_display(force_render=True)
1420
+
1421
+ logger.info(f"Command output displayed: {display_type}")
1422
+
1423
+ return {
1424
+ "success": True,
1425
+ "action": "command_output_displayed",
1426
+ "display_type": display_type,
1427
+ }
1428
+
1429
+ except Exception as e:
1430
+ logger.error(f"Error handling command output display: {e}")
1431
+ return {"success": False, "error": str(e)}
1432
+
1433
+ async def _handle_modal_trigger(
1434
+ self, event_data: Dict[str, Any], context: str = None
1435
+ ) -> Dict[str, Any]:
1436
+ """Handle modal trigger events to show modals.
1437
+
1438
+ Args:
1439
+ event_data: Event data containing modal configuration.
1440
+
1441
+ Returns:
1442
+ Dictionary with modal result.
1443
+ """
1444
+ try:
1445
+ # Check if this is a Matrix effect trigger
1446
+ if event_data.get("matrix_effect"):
1447
+ logger.info(
1448
+ "🎯 Matrix effect modal trigger received - setting modal mode for complete terminal control"
1449
+ )
1450
+ # Set modal mode directly for Matrix effect (no UI config needed)
1451
+ from ..events.models import CommandMode
1452
+
1453
+ self.command_mode = CommandMode.MODAL
1454
+ logger.info("🎯 Command mode set to MODAL for Matrix effect")
1455
+ return {
1456
+ "success": True,
1457
+ "modal_activated": True,
1458
+ "matrix_mode": True,
1459
+ }
1460
+
1461
+ # Check if this is a full-screen plugin trigger
1462
+ if event_data.get("fullscreen_plugin"):
1463
+ plugin_name = event_data.get("plugin_name", "unknown")
1464
+ logger.info(
1465
+ f"🎯 Full-screen plugin modal trigger received: {plugin_name}"
1466
+ )
1467
+
1468
+ # CRITICAL FIX: Clear input area before fullscreen mode (like config modal does)
1469
+ self.renderer.writing_messages = True
1470
+ self.renderer.clear_active_area()
1471
+
1472
+ # Set modal mode for full-screen plugin (no UI config needed)
1473
+ from ..events.models import CommandMode
1474
+
1475
+ self.command_mode = CommandMode.MODAL
1476
+ # CRITICAL FIX: Mark fullscreen session as active for input routing
1477
+ self._fullscreen_session_active = True
1478
+ logger.info(
1479
+ f"🎯 Command mode set to MODAL for full-screen plugin: {plugin_name}"
1480
+ )
1481
+ logger.info(
1482
+ "🎯 Fullscreen session marked as active for input routing"
1483
+ )
1484
+ return {
1485
+ "success": True,
1486
+ "modal_activated": True,
1487
+ "fullscreen_plugin": True,
1488
+ "plugin_name": plugin_name,
1489
+ }
1490
+
1491
+ # Standard modal with UI config
1492
+ ui_config = event_data.get("ui_config")
1493
+ if ui_config:
1494
+ logger.info(f"🎯 Modal trigger received: {ui_config.title}")
1495
+ await self._enter_modal_mode(ui_config)
1496
+ return {"success": True, "modal_activated": True}
1497
+ else:
1498
+ logger.warning("Modal trigger received without ui_config")
1499
+ return {"success": False, "error": "Missing ui_config"}
1500
+
1501
+ except Exception as e:
1502
+ logger.error(f"Error handling modal trigger: {e}")
1503
+ return {"success": False, "error": str(e)}
1504
+
1505
+ # ==================== SLASH COMMAND SYSTEM ====================
1506
+
1507
+ async def _enter_command_mode(self) -> None:
1508
+ """Enter slash command mode and show command menu."""
1509
+ try:
1510
+ logger.info("🎯 Entering slash command mode")
1511
+ self.command_mode = CommandMode.MENU_POPUP
1512
+ self.command_menu_active = True
1513
+
1514
+ # Reset selection to first command
1515
+ self.selected_command_index = 0
1516
+
1517
+ # Add the '/' character to buffer for visual feedback
1518
+ self.buffer_manager.insert_char("/")
1519
+
1520
+ # Show command menu via renderer
1521
+ available_commands = self._get_available_commands()
1522
+ self.command_menu_renderer.show_command_menu(available_commands, "")
1523
+
1524
+ # Emit command menu show event
1525
+ await self.event_bus.emit_with_hooks(
1526
+ EventType.COMMAND_MENU_SHOW,
1527
+ {"available_commands": available_commands, "filter_text": ""},
1528
+ "commands",
1529
+ )
1530
+
1531
+ # Update display to show command mode
1532
+ await self._update_display(force_render=True)
1533
+
1534
+ logger.info("Command menu activated")
1535
+
1536
+ except Exception as e:
1537
+ logger.error(f"Error entering command mode: {e}")
1538
+ await self._exit_command_mode()
1539
+
1540
+ async def _exit_command_mode(self) -> None:
1541
+ """Exit command mode and restore normal input."""
1542
+ try:
1543
+ import traceback
1544
+
1545
+ logger.info("🚪 Exiting slash command mode")
1546
+ logger.info(
1547
+ f"🚪 Exit called from: {traceback.format_stack()[-2].strip()}"
1548
+ )
1549
+
1550
+ # Hide command menu via renderer
1551
+ self.command_menu_renderer.hide_menu()
1552
+
1553
+ # Emit command menu hide event
1554
+ if self.command_menu_active:
1555
+ await self.event_bus.emit_with_hooks(
1556
+ EventType.COMMAND_MENU_HIDE,
1557
+ {"reason": "manual_exit"},
1558
+ "commands",
1559
+ )
1560
+
1561
+ self.command_mode = CommandMode.NORMAL
1562
+ self.command_menu_active = False
1563
+
1564
+ # Clear command buffer (remove the '/' and any partial command)
1565
+ self.buffer_manager.clear()
1566
+
1567
+ # Update display
1568
+ await self._update_display(force_render=True)
1569
+
1570
+ logger.info("Returned to normal input mode")
1571
+
1572
+ except Exception as e:
1573
+ logger.error(f"Error exiting command mode: {e}")
1574
+
1575
+ async def _handle_command_mode_keypress(self, key_press: KeyPress) -> bool:
1576
+ """Handle KeyPress while in command mode (supports arrow keys).
1577
+
1578
+ Args:
1579
+ key_press: Parsed key press to process.
1580
+
1581
+ Returns:
1582
+ True if key was handled, False to fall through to normal processing.
1583
+ """
1584
+ try:
1585
+ if self.command_mode == CommandMode.MENU_POPUP:
1586
+ return await self._handle_menu_popup_keypress(key_press)
1587
+ elif self.command_mode == CommandMode.STATUS_TAKEOVER:
1588
+ return await self._handle_status_takeover_keypress(key_press)
1589
+ elif self.command_mode == CommandMode.MODAL:
1590
+ return await self._handle_modal_keypress(key_press)
1591
+ elif self.command_mode == CommandMode.STATUS_MODAL:
1592
+ return await self._handle_status_modal_keypress(key_press)
1593
+ elif self.command_mode == CommandMode.LIVE_MODAL:
1594
+ return await self._handle_live_modal_keypress(key_press)
1595
+ else:
1596
+ # Unknown command mode, exit to normal
1597
+ await self._exit_command_mode()
1598
+ return False
1599
+
1600
+ except Exception as e:
1601
+ logger.error(f"Error handling command mode keypress: {e}")
1602
+ await self._exit_command_mode()
1603
+ return False
1604
+
1605
+ async def _handle_command_mode_input(self, char: str) -> bool:
1606
+ """Handle input while in command mode.
1607
+
1608
+ Args:
1609
+ char: Character input to process.
1610
+
1611
+ Returns:
1612
+ True if input was handled, False to fall through to normal processing.
1613
+ """
1614
+ try:
1615
+ if self.command_mode == CommandMode.MENU_POPUP:
1616
+ return await self._handle_menu_popup_input(char)
1617
+ elif self.command_mode == CommandMode.STATUS_TAKEOVER:
1618
+ return await self._handle_status_takeover_input(char)
1619
+ elif self.command_mode == CommandMode.STATUS_MODAL:
1620
+ return await self._handle_status_modal_input(char)
1621
+ elif self.command_mode == CommandMode.LIVE_MODAL:
1622
+ return await self._handle_live_modal_input(char)
1623
+ else:
1624
+ # Unknown command mode, exit to normal
1625
+ await self._exit_command_mode()
1626
+ return False
1627
+
1628
+ except Exception as e:
1629
+ logger.error(f"Error handling command mode input: {e}")
1630
+ await self._exit_command_mode()
1631
+ return False
1632
+
1633
+ async def _handle_menu_popup_input(self, char: str) -> bool:
1634
+ """Handle input during menu popup mode.
1635
+
1636
+ Args:
1637
+ char: Character input to process.
1638
+
1639
+ Returns:
1640
+ True if input was handled.
1641
+ """
1642
+ # Handle special keys first
1643
+ if ord(char) == 27: # Escape key
1644
+ await self._exit_command_mode()
1645
+ return True
1646
+ elif ord(char) == 13: # Enter key
1647
+ await self._execute_selected_command()
1648
+ return True
1649
+ elif ord(char) == 8 or ord(char) == 127: # Backspace or Delete
1650
+ # If buffer only has '/', exit command mode
1651
+ if len(self.buffer_manager.content) <= 1:
1652
+ await self._exit_command_mode()
1653
+ return True
1654
+ else:
1655
+ # Remove character and update command filter
1656
+ self.buffer_manager.delete_char()
1657
+ await self._update_command_filter()
1658
+ return True
1659
+
1660
+ # Handle printable characters (add to command filter)
1661
+ if char.isprintable():
1662
+ self.buffer_manager.insert_char(char)
1663
+ await self._update_command_filter()
1664
+ return True
1665
+
1666
+ # Let other keys fall through for now
1667
+ return False
1668
+
1669
+ async def _handle_menu_popup_keypress(self, key_press: KeyPress) -> bool:
1670
+ """Handle KeyPress during menu popup mode with arrow key navigation.
1671
+
1672
+ Args:
1673
+ key_press: Parsed key press to process.
1674
+
1675
+ Returns:
1676
+ True if key was handled.
1677
+ """
1678
+ try:
1679
+ # Handle arrow key navigation
1680
+ if key_press.name == "ArrowUp":
1681
+ await self._navigate_menu("up")
1682
+ return True
1683
+ elif key_press.name == "ArrowDown":
1684
+ await self._navigate_menu("down")
1685
+ return True
1686
+ elif key_press.name == "Enter":
1687
+ await self._execute_selected_command()
1688
+ return True
1689
+ elif key_press.name == "Escape":
1690
+ await self._exit_command_mode()
1691
+ return True
1692
+
1693
+ # Handle printable characters (for filtering)
1694
+ elif key_press.char and key_press.char.isprintable():
1695
+ self.buffer_manager.insert_char(key_press.char)
1696
+ await self._update_command_filter()
1697
+ return True
1698
+
1699
+ # Handle backspace/delete
1700
+ elif key_press.name in ["Backspace", "Delete"]:
1701
+ # If buffer only has '/', exit command mode
1702
+ if len(self.buffer_manager.content) <= 1:
1703
+ await self._exit_command_mode()
1704
+ return True
1705
+ else:
1706
+ # Remove character and update command filter
1707
+ self.buffer_manager.delete_char()
1708
+ await self._update_command_filter()
1709
+ return True
1710
+
1711
+ # Other keys not handled
1712
+ return False
1713
+
1714
+ except Exception as e:
1715
+ logger.error(f"Error handling menu popup keypress: {e}")
1716
+ await self._exit_command_mode()
1717
+ return False
1718
+
1719
+ async def _handle_status_takeover_input(self, char: str) -> bool:
1720
+ """Handle input during status area takeover mode.
1721
+
1722
+ Args:
1723
+ char: Character input to process.
1724
+
1725
+ Returns:
1726
+ True if input was handled.
1727
+ """
1728
+ # For now, just handle Escape to exit
1729
+ if ord(char) == 27: # Escape key
1730
+ await self._exit_command_mode()
1731
+ return True
1732
+
1733
+ # TODO: Implement status area navigation
1734
+ return True
1735
+
1736
+ async def _handle_status_takeover_keypress(self, key_press: KeyPress) -> bool:
1737
+ """Handle KeyPress during status area takeover mode.
1738
+
1739
+ Args:
1740
+ key_press: Parsed key press to process.
1741
+
1742
+ Returns:
1743
+ True if key was handled.
1744
+ """
1745
+ # For now, just handle Escape to exit
1746
+ if key_press.name == "Escape":
1747
+ await self._exit_command_mode()
1748
+ return True
1749
+
1750
+ # TODO: Implement status area navigation
1751
+ return True
1752
+
1753
+ async def _handle_modal_keypress(self, key_press: KeyPress) -> bool:
1754
+ """Handle KeyPress during modal mode.
1755
+
1756
+ Args:
1757
+ key_press: Parsed key press to process.
1758
+
1759
+ Returns:
1760
+ True if key was handled.
1761
+ """
1762
+ try:
1763
+ # CRITICAL FIX: Check if this is a fullscreen plugin session first
1764
+ if (
1765
+ hasattr(self, "_fullscreen_session_active")
1766
+ and self._fullscreen_session_active
1767
+ ):
1768
+ # SIMPLE SOLUTION: Check for exit keys directly
1769
+ if key_press.char in ["q", "\x1b"] or key_press.name == "Escape":
1770
+ # Exit fullscreen mode immediately
1771
+ self._fullscreen_session_active = False
1772
+ from ..events.models import CommandMode
1773
+
1774
+ self.command_mode = CommandMode.NORMAL
1775
+ await self._update_display(force_render=True)
1776
+ return True
1777
+
1778
+ # Route input to fullscreen session through event bus
1779
+ from ..events.models import EventType
1780
+
1781
+ await self.event_bus.emit_with_hooks(
1782
+ EventType.FULLSCREEN_INPUT,
1783
+ {"key_press": key_press, "source": "input_handler"},
1784
+ "input_handler",
1785
+ )
1786
+ return True
1787
+
1788
+ # Initialize modal renderer if needed
1789
+ if not self.modal_renderer:
1790
+ logger.warning(
1791
+ "Modal keypress received but no modal renderer active"
1792
+ )
1793
+ await self._exit_modal_mode()
1794
+ return True
1795
+
1796
+ # Handle save confirmation if active
1797
+ if self._pending_save_confirm:
1798
+ handled = await self._handle_save_confirmation(key_press)
1799
+ if handled:
1800
+ return True
1801
+
1802
+ # Handle navigation and widget interaction
1803
+ logger.info(f"🔍 Modal processing key: {key_press.name}")
1804
+
1805
+ nav_handled = self.modal_renderer._handle_widget_navigation(key_press)
1806
+ logger.info(f"🎯 Widget navigation handled: {nav_handled}")
1807
+ if nav_handled:
1808
+ # Re-render modal with updated focus
1809
+ await self._refresh_modal_display()
1810
+ return True
1811
+
1812
+ input_handled = self.modal_renderer._handle_widget_input(key_press)
1813
+ logger.info(f"🎯 Widget input handled: {input_handled}")
1814
+ if input_handled:
1815
+ # Re-render modal with updated widget state
1816
+ await self._refresh_modal_display()
1817
+ return True
1818
+
1819
+ if key_press.name == "Escape":
1820
+ logger.info("🚪 Processing Escape key for modal exit")
1821
+ # Check for unsaved changes
1822
+ if self.modal_renderer and self._has_pending_modal_changes():
1823
+ self._pending_save_confirm = True
1824
+ await self._show_save_confirmation()
1825
+ return True
1826
+ await self._exit_modal_mode()
1827
+ return True
1828
+ elif key_press.name == "Ctrl+S":
1829
+ logger.info("💾 Processing Ctrl+S for modal save")
1830
+ await self._save_and_exit_modal()
1831
+ return True
1832
+ elif key_press.name == "Enter":
1833
+ logger.info(
1834
+ "🔴 ENTER KEY HIJACKED - This should not happen if widget handled it!"
1835
+ )
1836
+ # Try to save modal changes and exit
1837
+ await self._save_and_exit_modal()
1838
+ return True
1839
+
1840
+ return True
1841
+ except Exception as e:
1842
+ logger.error(f"Error handling modal keypress: {e}")
1843
+ await self._exit_modal_mode()
1844
+ return False
1845
+
1846
+ async def _enter_modal_mode(self, ui_config):
1847
+ """Enter modal mode and show modal renderer.
1848
+
1849
+ Args:
1850
+ ui_config: Modal configuration.
1851
+ """
1852
+ try:
1853
+ # Import modal renderer here to avoid circular imports
1854
+ from ..ui.modal_renderer import ModalRenderer
1855
+
1856
+ # Create modal renderer instance with proper config service
1857
+ self.modal_renderer = ModalRenderer(
1858
+ terminal_renderer=self.renderer,
1859
+ visual_effects=getattr(self.renderer, "visual_effects", None),
1860
+ config_service=self.config, # Use config as config service
1861
+ )
1862
+
1863
+ # CRITICAL FIX: Clear input area before modal to prevent duplication
1864
+ self.renderer.writing_messages = True
1865
+ self.renderer.clear_active_area()
1866
+
1867
+ # Set modal mode FIRST
1868
+ self.command_mode = CommandMode.MODAL
1869
+ logger.info(f"🎯 Command mode set to: {self.command_mode}")
1870
+
1871
+ # Show the modal with alternate buffer
1872
+ logger.info(
1873
+ "🔧 DIRECT: About to call show_modal - this should trigger alternate buffer"
1874
+ )
1875
+ await self.modal_renderer.show_modal(ui_config)
1876
+
1877
+ # Reset writing flag (modal will handle its own rendering from here)
1878
+ self.renderer.writing_messages = False
1879
+
1880
+ logger.info("🎯 Entered modal mode with persistent input loop")
1881
+
1882
+ except Exception as e:
1883
+ logger.error(f"Error entering modal mode: {e}")
1884
+ self.command_mode = CommandMode.NORMAL
1885
+
1886
+ async def _refresh_modal_display(self):
1887
+ """Refresh modal display after widget interactions."""
1888
+ try:
1889
+ if self.modal_renderer and hasattr(
1890
+ self.modal_renderer, "current_ui_config"
1891
+ ):
1892
+
1893
+ # CRITICAL FIX: Force complete display clearing to prevent duplication
1894
+ # Clear active area completely before refresh
1895
+ self.renderer.clear_active_area()
1896
+
1897
+ # Clear any message buffers that might accumulate content
1898
+ if hasattr(self.renderer, "message_renderer"):
1899
+ if hasattr(self.renderer.message_renderer, "buffer"):
1900
+ self.renderer.message_renderer.buffer.clear_buffer()
1901
+ # Also clear any accumulated messages in the renderer
1902
+ if hasattr(self.renderer.message_renderer, "clear_messages"):
1903
+ self.renderer.message_renderer.clear_messages()
1904
+
1905
+ # Re-render the modal with current widget states (preserve widgets!)
1906
+ modal_lines = self.modal_renderer._render_modal_box(
1907
+ self.modal_renderer.current_ui_config,
1908
+ preserve_widgets=True,
1909
+ )
1910
+ # FIXED: Use state_manager.render_modal_content() instead of _render_modal_lines()
1911
+ # to avoid re-calling prepare_modal_display() which causes buffer switching
1912
+ if self.modal_renderer.state_manager:
1913
+ self.modal_renderer.state_manager.render_modal_content(
1914
+ modal_lines
1915
+ )
1916
+ else:
1917
+ # Fallback to old method if state_manager not available
1918
+ await self.modal_renderer._render_modal_lines(modal_lines)
1919
+ else:
1920
+ pass
1921
+ except Exception as e:
1922
+ logger.error(f"Error refreshing modal display: {e}")
1923
+
1924
+ def _has_pending_modal_changes(self) -> bool:
1925
+ """Check if there are unsaved changes in modal widgets."""
1926
+ if not self.modal_renderer or not self.modal_renderer.widgets:
1927
+ return False
1928
+ for widget in self.modal_renderer.widgets:
1929
+ if hasattr(widget, '_pending_value') and widget._pending_value is not None:
1930
+ # Check if pending value differs from current config value
1931
+ current = widget.get_value() if hasattr(widget, 'get_value') else None
1932
+ if widget._pending_value != current:
1933
+ return True
1934
+ return False
1935
+
1936
+ async def _show_save_confirmation(self):
1937
+ """Show save confirmation prompt in modal."""
1938
+ # Update modal footer to show confirmation prompt
1939
+ if self.modal_renderer:
1940
+ self.modal_renderer._save_confirm_active = True
1941
+ await self._refresh_modal_display()
1942
+
1943
+ async def _handle_save_confirmation(self, key_press) -> bool:
1944
+ """Handle y/n input for save confirmation."""
1945
+ if key_press.char and key_press.char.lower() == 'y':
1946
+ logger.info("💾 User confirmed save")
1947
+ self._pending_save_confirm = False
1948
+ if self.modal_renderer:
1949
+ self.modal_renderer._save_confirm_active = False
1950
+ await self._save_and_exit_modal()
1951
+ return True
1952
+ elif key_press.char and key_press.char.lower() == 'n':
1953
+ logger.info("🚫 User declined save")
1954
+ self._pending_save_confirm = False
1955
+ if self.modal_renderer:
1956
+ self.modal_renderer._save_confirm_active = False
1957
+ await self._exit_modal_mode()
1958
+ return True
1959
+ elif key_press.name == "Escape":
1960
+ # Cancel confirmation, stay in modal
1961
+ logger.info("↩️ User cancelled confirmation")
1962
+ self._pending_save_confirm = False
1963
+ if self.modal_renderer:
1964
+ self.modal_renderer._save_confirm_active = False
1965
+ await self._refresh_modal_display()
1966
+ return True
1967
+ return False
1968
+
1969
+ async def _save_and_exit_modal(self):
1970
+ """Save modal changes and exit modal mode."""
1971
+ try:
1972
+ if self.modal_renderer and hasattr(
1973
+ self.modal_renderer, "action_handler"
1974
+ ):
1975
+ # Get widget values and save them using proper action handler interface
1976
+ result = await self.modal_renderer.action_handler.handle_action(
1977
+ "save", self.modal_renderer.widgets
1978
+ )
1979
+ if result.get("success"):
1980
+ pass
1981
+ else:
1982
+ logger.warning(
1983
+ f"Failed to save modal changes: {result.get('message', 'Unknown error')}"
1984
+ )
1985
+
1986
+ await self._exit_modal_mode()
1987
+ except Exception as e:
1988
+ logger.error(f"Error saving and exiting modal: {e}")
1989
+ await self._exit_modal_mode()
1990
+
1991
+ async def _exit_modal_mode(self):
1992
+ """Exit modal mode using existing patterns."""
1993
+ try:
1994
+
1995
+ # CRITICAL FIX: Complete terminal state restoration
1996
+ # Clear active area to remove modal artifacts
1997
+ self.renderer.clear_active_area()
1998
+
1999
+ # Clear any buffered modal content that might persist
2000
+ if hasattr(self.renderer, "message_renderer"):
2001
+ if hasattr(self.renderer.message_renderer, "buffer"):
2002
+ self.renderer.message_renderer.buffer.clear_buffer()
2003
+
2004
+ # CRITICAL FIX: Properly close modal with alternate buffer restoration
2005
+ if self.modal_renderer:
2006
+ # FIRST: Close modal and restore terminal state (alternate buffer)
2007
+ _ = self.modal_renderer.close_modal()
2008
+
2009
+ # THEN: Reset modal renderer widgets
2010
+ self.modal_renderer.widgets = []
2011
+ self.modal_renderer.focused_widget_index = 0
2012
+ self.modal_renderer = None
2013
+
2014
+ # Return to normal mode
2015
+ self.command_mode = CommandMode.NORMAL
2016
+
2017
+ # Complete display restoration with force refresh
2018
+ self.renderer.clear_active_area()
2019
+ await self._update_display(force_render=True)
2020
+
2021
+ # Ensure cursor is properly positioned
2022
+ # Note: cursor management handled by terminal_state
2023
+
2024
+ except Exception as e:
2025
+ logger.error(f"Error exiting modal mode: {e}")
2026
+ self.command_mode = CommandMode.NORMAL
2027
+ self.modal_renderer = None
2028
+ # Emergency cleanup
2029
+ self.renderer.clear_active_area()
2030
+
2031
+ async def _navigate_menu(self, direction: str) -> None:
2032
+ """Navigate the command menu up or down.
2033
+
2034
+ Args:
2035
+ direction: "up" or "down"
2036
+ """
2037
+ try:
2038
+ # Get current filtered commands
2039
+ current_input = self.buffer_manager.content
2040
+ filter_text = (
2041
+ current_input[1:] if current_input.startswith("/") else current_input
2042
+ )
2043
+ filtered_commands = self._filter_commands(filter_text)
2044
+
2045
+ if not filtered_commands:
2046
+ return
2047
+
2048
+ # Update selection index
2049
+ if direction == "up":
2050
+ self.selected_command_index = max(0, self.selected_command_index - 1)
2051
+ elif direction == "down":
2052
+ self.selected_command_index = min(
2053
+ len(filtered_commands) - 1, self.selected_command_index + 1
2054
+ )
2055
+
2056
+ # Update menu renderer with new selection (don't reset selection during navigation)
2057
+ self.command_menu_renderer.set_selected_index(
2058
+ self.selected_command_index
2059
+ )
2060
+ self.command_menu_renderer.filter_commands(
2061
+ filtered_commands, filter_text, reset_selection=False
2062
+ )
2063
+
2064
+ # Note: No need to call _update_display - filter_commands already renders the menu
2065
+
2066
+ except Exception as e:
2067
+ logger.error(f"Error navigating menu: {e}")
2068
+
2069
+ async def _update_command_filter(self) -> None:
2070
+ """Update command menu based on current buffer content."""
2071
+ try:
2072
+ # Get current input (minus the leading '/')
2073
+ current_input = self.buffer_manager.content
2074
+ filter_text = (
2075
+ current_input[1:] if current_input.startswith("/") else current_input
2076
+ )
2077
+
2078
+ # Update menu renderer with filtered commands
2079
+ filtered_commands = self._filter_commands(filter_text)
2080
+
2081
+ # Reset selection when filtering
2082
+ self.selected_command_index = 0
2083
+ self.command_menu_renderer.set_selected_index(
2084
+ self.selected_command_index
2085
+ )
2086
+ self.command_menu_renderer.filter_commands(
2087
+ filtered_commands, filter_text
2088
+ )
2089
+
2090
+ # Emit filter update event
2091
+ await self.event_bus.emit_with_hooks(
2092
+ EventType.COMMAND_MENU_FILTER,
2093
+ {
2094
+ "filter_text": filter_text,
2095
+ "available_commands": self._get_available_commands(),
2096
+ "filtered_commands": filtered_commands,
2097
+ },
2098
+ "commands",
2099
+ )
2100
+
2101
+ # Update display
2102
+ await self._update_display(force_render=True)
2103
+
2104
+ except Exception as e:
2105
+ logger.error(f"Error updating command filter: {e}")
2106
+
2107
+ async def _execute_selected_command(self) -> None:
2108
+ """Execute the currently selected command."""
2109
+ try:
2110
+ # Get the full command string from buffer (includes arguments)
2111
+ command_string = self.buffer_manager.content
2112
+
2113
+ # If buffer is empty or just '/', use the selected command from menu
2114
+ if not command_string or command_string == "/":
2115
+ selected_command = self.command_menu_renderer.get_selected_command()
2116
+ if not selected_command:
2117
+ logger.warning("No command selected")
2118
+ await self._exit_command_mode()
2119
+ return
2120
+ command_string = f"/{selected_command['name']}"
2121
+
2122
+ # Parse the command
2123
+ command = self.slash_parser.parse_command(command_string)
2124
+ if command:
2125
+ logger.info(f"🚀 Executing selected command: {command.name}")
2126
+
2127
+ # Exit command mode first
2128
+ await self._exit_command_mode()
2129
+
2130
+ # Execute the command
2131
+ result = await self.command_executor.execute_command(
2132
+ command, self.event_bus
2133
+ )
2134
+
2135
+ # Handle the result
2136
+ if result.success:
2137
+ logger.info(f"✅ Command {command.name} completed successfully")
2138
+
2139
+ # Modal display is handled by event bus trigger, not here
2140
+ if result.message:
2141
+ # Display success message in status area
2142
+ logger.info(f"Command result: {result.message}")
2143
+ # TODO: Display in status area
2144
+ else:
2145
+ logger.warning(
2146
+ f"❌ Command {command.name} failed: {result.message}"
2147
+ )
2148
+ # TODO: Display error message in status area
2149
+ else:
2150
+ logger.warning("Failed to parse selected command")
2151
+ await self._exit_command_mode()
2152
+
2153
+ except Exception as e:
2154
+ logger.error(f"Error executing command: {e}")
2155
+ await self._exit_command_mode()
2156
+
2157
+ def _get_available_commands(self) -> List[Dict[str, Any]]:
2158
+ """Get list of available commands for menu display.
2159
+
2160
+ Returns:
2161
+ List of command dictionaries for menu rendering.
2162
+ """
2163
+ commands = []
2164
+ command_defs = self.command_registry.list_commands()
2165
+
2166
+ for cmd_def in command_defs:
2167
+ commands.append(
2168
+ {
2169
+ "name": cmd_def.name,
2170
+ "description": cmd_def.description,
2171
+ "aliases": cmd_def.aliases,
2172
+ "category": cmd_def.category.value,
2173
+ "plugin": cmd_def.plugin_name,
2174
+ "icon": cmd_def.icon,
2175
+ }
2176
+ )
2177
+
2178
+ return commands
2179
+
2180
+ def _filter_commands(self, filter_text: str) -> List[Dict[str, Any]]:
2181
+ """Filter commands based on input text.
2182
+
2183
+ Args:
2184
+ filter_text: Text to filter commands by.
2185
+
2186
+ Returns:
2187
+ List of filtered command dictionaries.
2188
+ """
2189
+ if not filter_text:
2190
+ return self._get_available_commands()
2191
+
2192
+ # Use registry search functionality
2193
+ matching_defs = self.command_registry.search_commands(filter_text)
2194
+
2195
+ filtered_commands = []
2196
+ for cmd_def in matching_defs:
2197
+ filtered_commands.append(
2198
+ {
2199
+ "name": cmd_def.name,
2200
+ "description": cmd_def.description,
2201
+ "aliases": cmd_def.aliases,
2202
+ "category": cmd_def.category.value,
2203
+ "plugin": cmd_def.plugin_name,
2204
+ "icon": cmd_def.icon,
2205
+ }
2206
+ )
2207
+
2208
+ return filtered_commands
2209
+
2210
+ # ==================== STATUS MODAL METHODS ====================
2211
+
2212
+ async def _handle_status_modal_trigger(
2213
+ self, event_data: Dict[str, Any], context: str = None
2214
+ ) -> Dict[str, Any]:
2215
+ """Handle status modal trigger events to show status modals.
2216
+
2217
+ Args:
2218
+ event_data: Event data containing modal configuration.
2219
+ context: Hook execution context.
2220
+
2221
+ Returns:
2222
+ Dictionary with status modal result.
2223
+ """
2224
+ try:
2225
+ ui_config = event_data.get("ui_config")
2226
+ if ui_config:
2227
+ logger.info(f"Status modal trigger received: {ui_config.title}")
2228
+ logger.info(f"Status modal trigger UI config type: {ui_config.type}")
2229
+ await self._enter_status_modal_mode(ui_config)
2230
+ return {"success": True, "status_modal_activated": True}
2231
+ else:
2232
+ logger.warning("Status modal trigger received without ui_config")
2233
+ return {"success": False, "error": "Missing ui_config"}
2234
+ except Exception as e:
2235
+ logger.error(f"Error handling status modal trigger: {e}")
2236
+ return {"success": False, "error": str(e)}
2237
+
2238
+ async def _enter_status_modal_mode(self, ui_config):
2239
+ """Enter status modal mode - modal confined to status area.
2240
+
2241
+ Args:
2242
+ ui_config: Status modal configuration.
2243
+ """
2244
+ try:
2245
+ # Set status modal mode
2246
+ self.command_mode = CommandMode.STATUS_MODAL
2247
+ self.current_status_modal_config = ui_config
2248
+ logger.info(f"Entered status modal mode: {ui_config.title}")
2249
+
2250
+ # Unlike full modals, status modals don't take over the screen
2251
+ # They just appear in the status area via the renderer
2252
+ await self._update_display(force_render=True)
2253
+
2254
+ except Exception as e:
2255
+ logger.error(f"Error entering status modal mode: {e}")
2256
+ await self._exit_command_mode()
2257
+
2258
+ async def _handle_status_modal_keypress(self, key_press: KeyPress) -> bool:
2259
+ """Handle keypress during status modal mode.
2260
+
2261
+ Args:
2262
+ key_press: Parsed key press to process.
2263
+
2264
+ Returns:
2265
+ True if key was handled, False otherwise.
2266
+ """
2267
+ try:
2268
+ logger.info(
2269
+ f"Status modal received key: name='{key_press.name}', char='{key_press.char}', code={key_press.code}"
2270
+ )
2271
+
2272
+ if key_press.name == "Escape":
2273
+ logger.info("Escape key detected, closing status modal")
2274
+ await self._exit_status_modal_mode()
2275
+ return True
2276
+ elif key_press.name == "Enter":
2277
+ logger.info("Enter key detected, closing status modal")
2278
+ await self._exit_status_modal_mode()
2279
+ return True
2280
+ elif key_press.char and ord(key_press.char) == 3: # Ctrl+C
2281
+ logger.info("Ctrl+C detected, closing status modal")
2282
+ await self._exit_status_modal_mode()
2283
+ return True
2284
+ else:
2285
+ logger.info(f"Unhandled key in status modal: {key_press.name}")
2286
+ return True
2287
+
2288
+ except Exception as e:
2289
+ logger.error(f"Error handling status modal keypress: {e}")
2290
+ await self._exit_status_modal_mode()
2291
+ return False
2292
+
2293
+ async def _handle_status_modal_input(self, char: str) -> bool:
2294
+ """Handle input during status modal mode.
2295
+
2296
+ Args:
2297
+ char: Character input to process.
2298
+
2299
+ Returns:
2300
+ True if input was handled, False otherwise.
2301
+ """
2302
+ try:
2303
+ # For now, ignore character input in status modals
2304
+ # Could add search/filter functionality later
2305
+ return True
2306
+ except Exception as e:
2307
+ logger.error(f"Error handling status modal input: {e}")
2308
+ await self._exit_status_modal_mode()
2309
+ return False
2310
+
2311
+ async def _exit_status_modal_mode(self):
2312
+ """Exit status modal mode and return to normal input."""
2313
+ try:
2314
+ logger.info("Exiting status modal mode...")
2315
+ self.command_mode = CommandMode.NORMAL
2316
+ self.current_status_modal_config = None
2317
+ logger.info("Status modal mode exited successfully")
2318
+
2319
+ # Refresh display to remove the status modal
2320
+ await self._update_display(force_render=True)
2321
+ logger.info("Display updated after status modal exit")
2322
+
2323
+ except Exception as e:
2324
+ logger.error(f"Error exiting status modal mode: {e}")
2325
+ self.command_mode = CommandMode.NORMAL
2326
+
2327
+ # ==================== Live Modal Mode ====================
2328
+ # For live-updating content like tmux sessions, log tailing, etc.
2329
+
2330
+ async def enter_live_modal_mode(
2331
+ self,
2332
+ content_generator,
2333
+ config=None,
2334
+ input_callback=None
2335
+ ) -> Dict[str, Any]:
2336
+ """Enter live modal mode with continuously updating content.
2337
+
2338
+ This is non-blocking - it starts the modal and returns immediately.
2339
+ The input loop continues to process keys, routing them to the modal.
2340
+ Press Escape to exit the modal.
2341
+
2342
+ Args:
2343
+ content_generator: Function returning List[str] of current content.
2344
+ config: LiveModalConfig instance.
2345
+ input_callback: Optional callback for input passthrough.
2346
+
2347
+ Returns:
2348
+ Result dict indicating modal was started.
2349
+ """
2350
+ try:
2351
+ from ..ui.live_modal_renderer import LiveModalRenderer, LiveModalConfig
2352
+
2353
+ # Store state
2354
+ self.command_mode = CommandMode.LIVE_MODAL
2355
+ self.live_modal_content_generator = content_generator
2356
+ self.live_modal_input_callback = input_callback
2357
+
2358
+ # Create and store the live modal renderer
2359
+ terminal_state = self.renderer.terminal_state
2360
+ self.live_modal_renderer = LiveModalRenderer(terminal_state)
2361
+
2362
+ # Use default config if none provided
2363
+ if config is None:
2364
+ config = LiveModalConfig()
2365
+
2366
+ logger.info(f"Entering live modal mode: {config.title}")
2367
+
2368
+ # Start the live modal (non-blocking)
2369
+ # The refresh loop runs as a background task
2370
+ # Input will be handled by _handle_live_modal_keypress
2371
+ success = self.live_modal_renderer.start_live_modal(
2372
+ content_generator,
2373
+ config,
2374
+ input_callback
2375
+ )
2376
+
2377
+ if success:
2378
+ return {"success": True, "modal_started": True}
2379
+ else:
2380
+ await self._exit_live_modal_mode()
2381
+ return {"success": False, "error": "Failed to start modal"}
2382
+
2383
+ except Exception as e:
2384
+ logger.error(f"Error entering live modal mode: {e}")
2385
+ await self._exit_live_modal_mode()
2386
+ return {"success": False, "error": str(e)}
2387
+
2388
+ async def _handle_live_modal_keypress(self, key_press: "KeyPress") -> bool:
2389
+ """Handle keypress during live modal mode.
2390
+
2391
+ Args:
2392
+ key_press: Parsed key press to process.
2393
+
2394
+ Returns:
2395
+ True if key was handled.
2396
+ """
2397
+ try:
2398
+ logger.info(
2399
+ f"LIVE_MODAL_KEY: name='{key_press.name}', char='{key_press.char}', code={key_press.code}"
2400
+ )
2401
+
2402
+ # Forward to live modal renderer
2403
+ if self.live_modal_renderer:
2404
+ should_close = await self.live_modal_renderer.handle_input(key_press)
2405
+ if should_close:
2406
+ await self._exit_live_modal_mode()
2407
+ return True
2408
+
2409
+ # Fallback: Escape always exits
2410
+ if key_press.name == "Escape":
2411
+ await self._exit_live_modal_mode()
2412
+ return True
2413
+
2414
+ return True
2415
+
2416
+ except Exception as e:
2417
+ logger.error(f"Error handling live modal keypress: {e}")
2418
+ await self._exit_live_modal_mode()
2419
+ return False
2420
+
2421
+ async def _handle_live_modal_input(self, char: str) -> bool:
2422
+ """Handle character input during live modal mode.
2423
+
2424
+ Args:
2425
+ char: Character input to process.
2426
+
2427
+ Returns:
2428
+ True if input was handled.
2429
+ """
2430
+ try:
2431
+ # Convert char to KeyPress for consistent handling
2432
+ from .key_parser import KeyPress
2433
+ key_press = KeyPress(char=char, name=None, code=ord(char) if char else 0)
2434
+ return await self._handle_live_modal_keypress(key_press)
2435
+
2436
+ except Exception as e:
2437
+ logger.error(f"Error handling live modal input: {e}")
2438
+ await self._exit_live_modal_mode()
2439
+ return False
2440
+
2441
+ async def _exit_live_modal_mode(self):
2442
+ """Exit live modal mode and restore terminal."""
2443
+ try:
2444
+ logger.info("Exiting live modal mode...")
2445
+
2446
+ # Close the live modal renderer (restores from alt buffer)
2447
+ if self.live_modal_renderer:
2448
+ await self.live_modal_renderer.close_modal()
2449
+
2450
+ # Reset state
2451
+ self.command_mode = CommandMode.NORMAL
2452
+ self.live_modal_renderer = None
2453
+ self.live_modal_content_generator = None
2454
+ self.live_modal_input_callback = None
2455
+
2456
+ # Force display refresh with full redraw
2457
+ self.renderer.clear_active_area()
2458
+ await self._update_display(force_render=True)
2459
+
2460
+ logger.info("Live modal mode exited successfully")
2461
+
2462
+ except Exception as e:
2463
+ logger.error(f"Error exiting live modal mode: {e}")
2464
+ self.command_mode = CommandMode.NORMAL
2465
+
2466
+ async def _handle_status_modal_render(
2467
+ self, event_data: Dict[str, Any], context: str = None
2468
+ ) -> Dict[str, Any]:
2469
+ """Handle status modal render events to provide modal display lines.
2470
+
2471
+ Args:
2472
+ event_data: Event data containing render request.
2473
+ context: Hook execution context.
2474
+
2475
+ Returns:
2476
+ Dictionary with status modal lines if active.
2477
+ """
2478
+ try:
2479
+ if (
2480
+ self.command_mode == CommandMode.STATUS_MODAL
2481
+ and self.current_status_modal_config
2482
+ ):
2483
+
2484
+ # Generate status modal display lines
2485
+ modal_lines = self._generate_status_modal_lines(
2486
+ self.current_status_modal_config
2487
+ )
2488
+
2489
+ return {"success": True, "status_modal_lines": modal_lines}
2490
+ else:
2491
+ return {"success": True, "status_modal_lines": []}
2492
+
2493
+ except Exception as e:
2494
+ logger.error(f"Error handling status modal render: {e}")
2495
+ return {"success": False, "status_modal_lines": []}
2496
+
2497
+ def _generate_status_modal_lines(self, ui_config) -> List[str]:
2498
+ """Generate formatted lines for status modal display using visual effects.
2499
+
2500
+ Args:
2501
+ ui_config: UI configuration for the status modal.
2502
+
2503
+ Returns:
2504
+ List of formatted lines for display.
2505
+ """
2506
+ try:
2507
+ # Get dynamic terminal width
2508
+ terminal_width = getattr(self.renderer.terminal_state, "width", 80)
2509
+ # Reserve space for borders and padding (│ content │ = 4 chars total)
2510
+ content_width = terminal_width - 6 # Leave 6 for borders/padding
2511
+ max_line_length = content_width - 4 # Additional safety margin
2512
+
2513
+ content_lines = []
2514
+
2515
+ # Modal content based on config (no duplicate headers)
2516
+ modal_config = ui_config.modal_config or {}
2517
+
2518
+ if "sections" in modal_config:
2519
+ for section in modal_config["sections"]:
2520
+ # Skip section title since it's redundant with modal title
2521
+ # Display commands directly
2522
+ commands = section.get("commands", [])
2523
+ for cmd in commands:
2524
+ name = cmd.get("name", "")
2525
+ description = cmd.get("description", "")
2526
+
2527
+ # Format command line with better alignment, using dynamic width
2528
+ cmd_line = f"{name:<28} {description}"
2529
+ if len(cmd_line) > max_line_length:
2530
+ cmd_line = cmd_line[: max_line_length - 3] + "..."
2531
+
2532
+ content_lines.append(cmd_line)
2533
+
2534
+ # Add spacing before footer
2535
+ content_lines.append("")
2536
+
2537
+ # Modal footer with special styling marker
2538
+ footer = modal_config.get(
2539
+ "footer",
2540
+ "Press Esc to close • Use /help <command> for detailed help",
2541
+ )
2542
+ content_lines.append(f"__FOOTER__{footer}")
2543
+
2544
+ # Clean content lines for box rendering (no ANSI codes)
2545
+ clean_content = []
2546
+ for line in content_lines:
2547
+ if line.startswith("__FOOTER__"):
2548
+ footer_text = line.replace("__FOOTER__", "")
2549
+ clean_content.append(footer_text)
2550
+ else:
2551
+ clean_content.append(line)
2552
+
2553
+ # Use BoxRenderer from enhanced input plugin if available
2554
+ try:
2555
+ from ..plugins.enhanced_input.box_renderer import BoxRenderer
2556
+ from ..plugins.enhanced_input.box_styles import (
2557
+ BoxStyleRegistry,
2558
+ )
2559
+ from ..plugins.enhanced_input.color_engine import ColorEngine
2560
+ from ..plugins.enhanced_input.geometry import (
2561
+ GeometryCalculator,
2562
+ )
2563
+ from ..plugins.enhanced_input.text_processor import (
2564
+ TextProcessor,
2565
+ )
2566
+
2567
+ # Initialize components
2568
+ style_registry = BoxStyleRegistry()
2569
+ color_engine = ColorEngine()
2570
+ geometry = GeometryCalculator()
2571
+ text_processor = TextProcessor()
2572
+ box_renderer = BoxRenderer(
2573
+ style_registry, color_engine, geometry, text_processor
2574
+ )
2575
+
2576
+ # Render with clean rounded style first, using dynamic width
2577
+ bordered_lines = box_renderer.render_box(
2578
+ clean_content, content_width, "rounded"
2579
+ )
2580
+
2581
+ # Add title to top border
2582
+ title = ui_config.title or "Status Modal"
2583
+ if bordered_lines:
2584
+ _ = bordered_lines[0]
2585
+ # Create title border: ╭─ Title ─────...─╮
2586
+ title_text = f"─ {title} "
2587
+ remaining_width = max(
2588
+ 0, content_width - 2 - len(title_text)
2589
+ ) # content_width - 2 border chars - title length
2590
+ titled_border = f"╭{title_text}{'─' * remaining_width}╮"
2591
+ bordered_lines[0] = titled_border
2592
+
2593
+ # Apply styling to content lines after border rendering
2594
+ styled_lines = []
2595
+ for i, line in enumerate(bordered_lines):
2596
+ if i == 0 or i == len(bordered_lines) - 1:
2597
+ # Border lines - keep as is
2598
+ styled_lines.append(line)
2599
+ elif line.strip() and "│" in line:
2600
+ # Content lines with borders
2601
+ if any(
2602
+ footer in line for footer in ["Press Esc", "Use /help"]
2603
+ ):
2604
+ # Footer line - apply cyan
2605
+ styled_line = line.replace("│", "│\033[2;36m", 1)
2606
+ styled_line = styled_line.replace("│", "\033[0m│", -1)
2607
+ styled_lines.append(styled_line)
2608
+ elif line.strip() != "│" + " " * 76 + "│": # Not empty line
2609
+ # Command line - apply dim
2610
+ styled_line = line.replace("│", "│\033[2m", 1)
2611
+ styled_line = styled_line.replace("│", "\033[0m│", -1)
2612
+ styled_lines.append(styled_line)
2613
+ else:
2614
+ # Empty line
2615
+ styled_lines.append(line)
2616
+ else:
2617
+ styled_lines.append(line)
2618
+
2619
+ return styled_lines
2620
+
2621
+ except ImportError:
2622
+ # Fallback to simple manual borders if enhanced input not available
2623
+ return self._create_simple_bordered_content(clean_content)
2624
+
2625
+ except Exception as e:
2626
+ logger.error(f"Error generating status modal lines: {e}")
2627
+ return [f"Error displaying status modal: {e}"]
2628
+
2629
+ def _create_simple_bordered_content(self, content_lines: List[str]) -> List[str]:
2630
+ """Create simple bordered content as fallback.
2631
+
2632
+ Args:
2633
+ content_lines: Content lines to border.
2634
+
2635
+ Returns:
2636
+ Lines with simple borders.
2637
+ """
2638
+ # Get dynamic terminal width
2639
+ terminal_width = getattr(self.renderer.terminal_state, "width", 80)
2640
+ # Reserve space for borders and padding
2641
+ width = terminal_width - 6 # Leave 6 for borders/padding
2642
+ lines = []
2643
+
2644
+ # Simple top border
2645
+ lines.append("╭" + "─" * (width + 2) + "╮")
2646
+
2647
+ # Content with side borders
2648
+ for line in content_lines:
2649
+ padded_line = f"{line:<{width}}"
2650
+ lines.append(f"│ {padded_line} │")
2651
+
2652
+ # Simple bottom border
2653
+ lines.append("╰" + "─" * (width + 2) + "╯")
2654
+
2655
+ return lines