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.
- core/__init__.py +18 -0
- core/application.py +578 -0
- core/cli.py +193 -0
- core/commands/__init__.py +43 -0
- core/commands/executor.py +277 -0
- core/commands/menu_renderer.py +319 -0
- core/commands/parser.py +186 -0
- core/commands/registry.py +331 -0
- core/commands/system_commands.py +479 -0
- core/config/__init__.py +7 -0
- core/config/llm_task_config.py +110 -0
- core/config/loader.py +501 -0
- core/config/manager.py +112 -0
- core/config/plugin_config_manager.py +346 -0
- core/config/plugin_schema.py +424 -0
- core/config/service.py +399 -0
- core/effects/__init__.py +1 -0
- core/events/__init__.py +12 -0
- core/events/bus.py +129 -0
- core/events/executor.py +154 -0
- core/events/models.py +258 -0
- core/events/processor.py +176 -0
- core/events/registry.py +289 -0
- core/fullscreen/__init__.py +19 -0
- core/fullscreen/command_integration.py +290 -0
- core/fullscreen/components/__init__.py +12 -0
- core/fullscreen/components/animation.py +258 -0
- core/fullscreen/components/drawing.py +160 -0
- core/fullscreen/components/matrix_components.py +177 -0
- core/fullscreen/manager.py +302 -0
- core/fullscreen/plugin.py +204 -0
- core/fullscreen/renderer.py +282 -0
- core/fullscreen/session.py +324 -0
- core/io/__init__.py +52 -0
- core/io/buffer_manager.py +362 -0
- core/io/config_status_view.py +272 -0
- core/io/core_status_views.py +410 -0
- core/io/input_errors.py +313 -0
- core/io/input_handler.py +2655 -0
- core/io/input_mode_manager.py +402 -0
- core/io/key_parser.py +344 -0
- core/io/layout.py +587 -0
- core/io/message_coordinator.py +204 -0
- core/io/message_renderer.py +601 -0
- core/io/modal_interaction_handler.py +315 -0
- core/io/raw_input_processor.py +946 -0
- core/io/status_renderer.py +845 -0
- core/io/terminal_renderer.py +586 -0
- core/io/terminal_state.py +551 -0
- core/io/visual_effects.py +734 -0
- core/llm/__init__.py +26 -0
- core/llm/api_communication_service.py +863 -0
- core/llm/conversation_logger.py +473 -0
- core/llm/conversation_manager.py +414 -0
- core/llm/file_operations_executor.py +1401 -0
- core/llm/hook_system.py +402 -0
- core/llm/llm_service.py +1629 -0
- core/llm/mcp_integration.py +386 -0
- core/llm/message_display_service.py +450 -0
- core/llm/model_router.py +214 -0
- core/llm/plugin_sdk.py +396 -0
- core/llm/response_parser.py +848 -0
- core/llm/response_processor.py +364 -0
- core/llm/tool_executor.py +520 -0
- core/logging/__init__.py +19 -0
- core/logging/setup.py +208 -0
- core/models/__init__.py +5 -0
- core/models/base.py +23 -0
- core/plugins/__init__.py +13 -0
- core/plugins/collector.py +212 -0
- core/plugins/discovery.py +386 -0
- core/plugins/factory.py +263 -0
- core/plugins/registry.py +152 -0
- core/storage/__init__.py +5 -0
- core/storage/state_manager.py +84 -0
- core/ui/__init__.py +6 -0
- core/ui/config_merger.py +176 -0
- core/ui/config_widgets.py +369 -0
- core/ui/live_modal_renderer.py +276 -0
- core/ui/modal_actions.py +162 -0
- core/ui/modal_overlay_renderer.py +373 -0
- core/ui/modal_renderer.py +591 -0
- core/ui/modal_state_manager.py +443 -0
- core/ui/widget_integration.py +222 -0
- core/ui/widgets/__init__.py +27 -0
- core/ui/widgets/base_widget.py +136 -0
- core/ui/widgets/checkbox.py +85 -0
- core/ui/widgets/dropdown.py +140 -0
- core/ui/widgets/label.py +78 -0
- core/ui/widgets/slider.py +185 -0
- core/ui/widgets/text_input.py +224 -0
- core/utils/__init__.py +11 -0
- core/utils/config_utils.py +656 -0
- core/utils/dict_utils.py +212 -0
- core/utils/error_utils.py +275 -0
- core/utils/key_reader.py +171 -0
- core/utils/plugin_utils.py +267 -0
- core/utils/prompt_renderer.py +151 -0
- kollabor-0.4.9.dist-info/METADATA +298 -0
- kollabor-0.4.9.dist-info/RECORD +128 -0
- kollabor-0.4.9.dist-info/WHEEL +5 -0
- kollabor-0.4.9.dist-info/entry_points.txt +2 -0
- kollabor-0.4.9.dist-info/licenses/LICENSE +21 -0
- kollabor-0.4.9.dist-info/top_level.txt +4 -0
- kollabor_cli_main.py +20 -0
- plugins/__init__.py +1 -0
- plugins/enhanced_input/__init__.py +18 -0
- plugins/enhanced_input/box_renderer.py +103 -0
- plugins/enhanced_input/box_styles.py +142 -0
- plugins/enhanced_input/color_engine.py +165 -0
- plugins/enhanced_input/config.py +150 -0
- plugins/enhanced_input/cursor_manager.py +72 -0
- plugins/enhanced_input/geometry.py +81 -0
- plugins/enhanced_input/state.py +130 -0
- plugins/enhanced_input/text_processor.py +115 -0
- plugins/enhanced_input_plugin.py +385 -0
- plugins/fullscreen/__init__.py +9 -0
- plugins/fullscreen/example_plugin.py +327 -0
- plugins/fullscreen/matrix_plugin.py +132 -0
- plugins/hook_monitoring_plugin.py +1299 -0
- plugins/query_enhancer_plugin.py +350 -0
- plugins/save_conversation_plugin.py +502 -0
- plugins/system_commands_plugin.py +93 -0
- plugins/tmux_plugin.py +795 -0
- plugins/workflow_enforcement_plugin.py +629 -0
- system_prompt/default.md +1286 -0
- system_prompt/default_win.md +265 -0
- system_prompt/example_with_trender.md +47 -0
core/io/input_handler.py
ADDED
|
@@ -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
|