kollabor 0.4.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. core/__init__.py +18 -0
  2. core/application.py +578 -0
  3. core/cli.py +193 -0
  4. core/commands/__init__.py +43 -0
  5. core/commands/executor.py +277 -0
  6. core/commands/menu_renderer.py +319 -0
  7. core/commands/parser.py +186 -0
  8. core/commands/registry.py +331 -0
  9. core/commands/system_commands.py +479 -0
  10. core/config/__init__.py +7 -0
  11. core/config/llm_task_config.py +110 -0
  12. core/config/loader.py +501 -0
  13. core/config/manager.py +112 -0
  14. core/config/plugin_config_manager.py +346 -0
  15. core/config/plugin_schema.py +424 -0
  16. core/config/service.py +399 -0
  17. core/effects/__init__.py +1 -0
  18. core/events/__init__.py +12 -0
  19. core/events/bus.py +129 -0
  20. core/events/executor.py +154 -0
  21. core/events/models.py +258 -0
  22. core/events/processor.py +176 -0
  23. core/events/registry.py +289 -0
  24. core/fullscreen/__init__.py +19 -0
  25. core/fullscreen/command_integration.py +290 -0
  26. core/fullscreen/components/__init__.py +12 -0
  27. core/fullscreen/components/animation.py +258 -0
  28. core/fullscreen/components/drawing.py +160 -0
  29. core/fullscreen/components/matrix_components.py +177 -0
  30. core/fullscreen/manager.py +302 -0
  31. core/fullscreen/plugin.py +204 -0
  32. core/fullscreen/renderer.py +282 -0
  33. core/fullscreen/session.py +324 -0
  34. core/io/__init__.py +52 -0
  35. core/io/buffer_manager.py +362 -0
  36. core/io/config_status_view.py +272 -0
  37. core/io/core_status_views.py +410 -0
  38. core/io/input_errors.py +313 -0
  39. core/io/input_handler.py +2655 -0
  40. core/io/input_mode_manager.py +402 -0
  41. core/io/key_parser.py +344 -0
  42. core/io/layout.py +587 -0
  43. core/io/message_coordinator.py +204 -0
  44. core/io/message_renderer.py +601 -0
  45. core/io/modal_interaction_handler.py +315 -0
  46. core/io/raw_input_processor.py +946 -0
  47. core/io/status_renderer.py +845 -0
  48. core/io/terminal_renderer.py +586 -0
  49. core/io/terminal_state.py +551 -0
  50. core/io/visual_effects.py +734 -0
  51. core/llm/__init__.py +26 -0
  52. core/llm/api_communication_service.py +863 -0
  53. core/llm/conversation_logger.py +473 -0
  54. core/llm/conversation_manager.py +414 -0
  55. core/llm/file_operations_executor.py +1401 -0
  56. core/llm/hook_system.py +402 -0
  57. core/llm/llm_service.py +1629 -0
  58. core/llm/mcp_integration.py +386 -0
  59. core/llm/message_display_service.py +450 -0
  60. core/llm/model_router.py +214 -0
  61. core/llm/plugin_sdk.py +396 -0
  62. core/llm/response_parser.py +848 -0
  63. core/llm/response_processor.py +364 -0
  64. core/llm/tool_executor.py +520 -0
  65. core/logging/__init__.py +19 -0
  66. core/logging/setup.py +208 -0
  67. core/models/__init__.py +5 -0
  68. core/models/base.py +23 -0
  69. core/plugins/__init__.py +13 -0
  70. core/plugins/collector.py +212 -0
  71. core/plugins/discovery.py +386 -0
  72. core/plugins/factory.py +263 -0
  73. core/plugins/registry.py +152 -0
  74. core/storage/__init__.py +5 -0
  75. core/storage/state_manager.py +84 -0
  76. core/ui/__init__.py +6 -0
  77. core/ui/config_merger.py +176 -0
  78. core/ui/config_widgets.py +369 -0
  79. core/ui/live_modal_renderer.py +276 -0
  80. core/ui/modal_actions.py +162 -0
  81. core/ui/modal_overlay_renderer.py +373 -0
  82. core/ui/modal_renderer.py +591 -0
  83. core/ui/modal_state_manager.py +443 -0
  84. core/ui/widget_integration.py +222 -0
  85. core/ui/widgets/__init__.py +27 -0
  86. core/ui/widgets/base_widget.py +136 -0
  87. core/ui/widgets/checkbox.py +85 -0
  88. core/ui/widgets/dropdown.py +140 -0
  89. core/ui/widgets/label.py +78 -0
  90. core/ui/widgets/slider.py +185 -0
  91. core/ui/widgets/text_input.py +224 -0
  92. core/utils/__init__.py +11 -0
  93. core/utils/config_utils.py +656 -0
  94. core/utils/dict_utils.py +212 -0
  95. core/utils/error_utils.py +275 -0
  96. core/utils/key_reader.py +171 -0
  97. core/utils/plugin_utils.py +267 -0
  98. core/utils/prompt_renderer.py +151 -0
  99. kollabor-0.4.9.dist-info/METADATA +298 -0
  100. kollabor-0.4.9.dist-info/RECORD +128 -0
  101. kollabor-0.4.9.dist-info/WHEEL +5 -0
  102. kollabor-0.4.9.dist-info/entry_points.txt +2 -0
  103. kollabor-0.4.9.dist-info/licenses/LICENSE +21 -0
  104. kollabor-0.4.9.dist-info/top_level.txt +4 -0
  105. kollabor_cli_main.py +20 -0
  106. plugins/__init__.py +1 -0
  107. plugins/enhanced_input/__init__.py +18 -0
  108. plugins/enhanced_input/box_renderer.py +103 -0
  109. plugins/enhanced_input/box_styles.py +142 -0
  110. plugins/enhanced_input/color_engine.py +165 -0
  111. plugins/enhanced_input/config.py +150 -0
  112. plugins/enhanced_input/cursor_manager.py +72 -0
  113. plugins/enhanced_input/geometry.py +81 -0
  114. plugins/enhanced_input/state.py +130 -0
  115. plugins/enhanced_input/text_processor.py +115 -0
  116. plugins/enhanced_input_plugin.py +385 -0
  117. plugins/fullscreen/__init__.py +9 -0
  118. plugins/fullscreen/example_plugin.py +327 -0
  119. plugins/fullscreen/matrix_plugin.py +132 -0
  120. plugins/hook_monitoring_plugin.py +1299 -0
  121. plugins/query_enhancer_plugin.py +350 -0
  122. plugins/save_conversation_plugin.py +502 -0
  123. plugins/system_commands_plugin.py +93 -0
  124. plugins/tmux_plugin.py +795 -0
  125. plugins/workflow_enforcement_plugin.py +629 -0
  126. system_prompt/default.md +1286 -0
  127. system_prompt/default_win.md +265 -0
  128. system_prompt/example_with_trender.md +47 -0
@@ -0,0 +1,591 @@
1
+ """Modal renderer using existing visual effects infrastructure."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import re
6
+ from typing import List, Dict, Any, Optional
7
+
8
+ from ..events.models import UIConfig
9
+ from ..io.visual_effects import ColorPalette, GradientRenderer
10
+ from ..io.key_parser import KeyPress
11
+ from .widgets import BaseWidget, CheckboxWidget, DropdownWidget, TextInputWidget, SliderWidget, LabelWidget
12
+ from .config_merger import ConfigMerger
13
+ from .modal_actions import ModalActionHandler
14
+ from .modal_overlay_renderer import ModalOverlayRenderer
15
+ from .modal_state_manager import ModalStateManager, ModalLayout, ModalDisplayMode
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class ModalRenderer:
21
+ """Modal overlay renderer using existing visual effects system."""
22
+
23
+ def __init__(self, terminal_renderer, visual_effects, config_service=None):
24
+ """Initialize modal renderer with existing infrastructure.
25
+
26
+ Args:
27
+ terminal_renderer: Terminal renderer for output.
28
+ visual_effects: Visual effects system for styling.
29
+ config_service: ConfigService for config persistence.
30
+ """
31
+ self.terminal_renderer = terminal_renderer
32
+ self.visual_effects = visual_effects
33
+ self.gradient_renderer = GradientRenderer()
34
+ self.config_service = config_service
35
+
36
+ # NEW: Initialize overlay rendering system for proper modal display
37
+ if terminal_renderer and hasattr(terminal_renderer, 'terminal_state'):
38
+ self.overlay_renderer = ModalOverlayRenderer(terminal_renderer.terminal_state)
39
+ self.state_manager = ModalStateManager(terminal_renderer.terminal_state)
40
+ else:
41
+ # Fallback for testing or when terminal_renderer is not available
42
+ self.overlay_renderer = None
43
+ self.state_manager = None
44
+
45
+ # Widget management
46
+ self.widgets: List[BaseWidget] = []
47
+ self.focused_widget_index = 0
48
+ self.scroll_offset = 0
49
+ self.visible_height = 20 # Number of widget lines visible at once
50
+ self._save_confirm_active = False # For save confirmation prompt
51
+
52
+ # Action handling
53
+ self.action_handler = ModalActionHandler(config_service) if config_service else None
54
+
55
+ async def show_modal(self, ui_config: UIConfig) -> Dict[str, Any]:
56
+ """Show modal overlay using TRUE overlay system.
57
+
58
+ Args:
59
+ ui_config: Modal configuration.
60
+
61
+ Returns:
62
+ Modal interaction result.
63
+ """
64
+ try:
65
+ # FIXED: Use overlay system instead of chat pipeline clearing
66
+ # No more clear_active_area() - that only clears display, not buffers
67
+
68
+ # Render modal using existing visual effects (content generation)
69
+ modal_lines = self._render_modal_box(ui_config)
70
+
71
+ # Use overlay rendering instead of animation that routes through chat
72
+ await self._render_modal_lines(modal_lines)
73
+
74
+ return await self._handle_modal_input(ui_config)
75
+ except Exception as e:
76
+ logger.error(f"Error showing modal: {e}")
77
+ # Ensure proper cleanup on error
78
+ if self.state_manager:
79
+ self.state_manager.restore_terminal_state()
80
+ return {"success": False, "error": str(e)}
81
+
82
+ def refresh_modal_display(self) -> bool:
83
+ """Refresh modal display without accumulation using overlay system.
84
+
85
+ This method refreshes the current modal content without any
86
+ interaction with conversation buffers or message systems.
87
+
88
+ Returns:
89
+ True if refresh was successful.
90
+ """
91
+ try:
92
+ # Use state manager to refresh display without chat pipeline
93
+ if self.state_manager:
94
+ return self.state_manager.refresh_modal_display()
95
+ else:
96
+ logger.warning("State manager not available - fallback refresh")
97
+ return True
98
+ except Exception as e:
99
+ logger.error(f"Error refreshing modal display: {e}")
100
+ return False
101
+
102
+ def close_modal(self) -> bool:
103
+ """Close modal and restore terminal state.
104
+
105
+ Returns:
106
+ True if modal was closed successfully.
107
+ """
108
+ try:
109
+ # Use state manager to properly restore terminal state
110
+ if self.state_manager:
111
+ return self.state_manager.restore_terminal_state()
112
+ else:
113
+ logger.warning("State manager not available - fallback close")
114
+ return True
115
+ except Exception as e:
116
+ logger.error(f"Error closing modal: {e}")
117
+ return False
118
+
119
+ def _render_modal_box(self, ui_config: UIConfig, preserve_widgets: bool = False) -> List[str]:
120
+ """Render modal box using existing ColorPalette.
121
+
122
+ Args:
123
+ ui_config: Modal configuration.
124
+ preserve_widgets: If True, preserve existing widget states instead of recreating.
125
+
126
+ Returns:
127
+ List of rendered modal lines.
128
+ """
129
+ # Use existing ColorPalette for styling
130
+ border_color = ColorPalette.GREY
131
+ title_color = ColorPalette.BRIGHT_WHITE
132
+ footer_color = ColorPalette.GREY
133
+ # Use dynamic terminal width instead of hardcoded values
134
+ terminal_width = getattr(self.terminal_renderer.terminal_state, 'width', 80) if self.terminal_renderer else 80
135
+ width = min(int(ui_config.width or terminal_width), terminal_width)
136
+ title = ui_config.title or "Modal"
137
+
138
+ lines = []
139
+
140
+ # Top border with colored title embedded
141
+ title_separators = "─"
142
+ remaining_width = max(0, width - 2 - len(title) - 2) # -2 for separators
143
+ left_padding = remaining_width // 2
144
+ right_padding = remaining_width - left_padding
145
+ title_border = f"{border_color}╭{'─' * left_padding}{title_separators}{title_color}{title}{ColorPalette.RESET}{border_color}{title_separators}{'─' * right_padding}╮{ColorPalette.RESET}"
146
+ lines.append(title_border)
147
+
148
+ # Content area
149
+ # Use actual width for content rendering
150
+ actual_content_width = width - 2 # Remove padding from width for content
151
+ content_lines = self._render_modal_content(ui_config.modal_config or {}, actual_content_width + 2, preserve_widgets)
152
+ lines.extend(content_lines)
153
+
154
+ # Bottom border with footer embedded
155
+ if self._save_confirm_active:
156
+ footer = "Save changes? (Y)es / (N)o / (Esc) cancel"
157
+ footer_color = ColorPalette.BRIGHT_YELLOW
158
+ else:
159
+ footer = (ui_config.modal_config or {}).get("footer", "enter to select • esc to close")
160
+ footer_remaining = max(0, width - 2 - len(footer))
161
+ footer_left = footer_remaining // 2
162
+ footer_right = footer_remaining - footer_left
163
+ footer_border = f"{border_color}╰{'─' * footer_left}{footer_color}{footer}{ColorPalette.RESET}{border_color}{'─' * footer_right}╯{ColorPalette.RESET}"
164
+ lines.append(footer_border)
165
+
166
+ return lines
167
+
168
+ def _render_modal_content(self, modal_config: dict, width: int, preserve_widgets: bool = False) -> List[str]:
169
+ """Render modal content with interactive widgets and scrolling.
170
+
171
+ Args:
172
+ modal_config: Modal configuration dict.
173
+ width: Modal width.
174
+ preserve_widgets: If True, preserve existing widget states instead of recreating.
175
+
176
+ Returns:
177
+ List of content lines with rendered widgets.
178
+ """
179
+ all_lines = [] # All content lines before pagination
180
+ border_color = ColorPalette.GREY # Modal border color
181
+
182
+ # Create or preserve widgets based on mode
183
+ if not preserve_widgets:
184
+ self.widgets = []
185
+ self.focused_widget_index = 0
186
+ self.scroll_offset = 0
187
+ self.widgets = self._create_widgets(modal_config)
188
+ if self.widgets:
189
+ self.widgets[0].set_focus(True)
190
+
191
+ # Build all content lines with widget indices
192
+ widget_index = 0
193
+ widget_line_map = [] # Maps line index to widget index
194
+ sections = modal_config.get("sections", [])
195
+
196
+ for section_idx, section in enumerate(sections):
197
+ section_title = section.get("title", "Section")
198
+ # Use lime green for section headers
199
+ title_text = f" {ColorPalette.LIME_LIGHT}{section_title}{ColorPalette.RESET}"
200
+ # Build line with proper color separation: border | content | border
201
+ title_line = f"{border_color}│{ColorPalette.RESET}{self._pad_line_with_ansi(title_text, width-2)}{border_color}│{ColorPalette.RESET}"
202
+ all_lines.append(title_line)
203
+ widget_line_map.append(-1) # Section header, no widget
204
+
205
+ section_widgets = section.get("widgets", [])
206
+ if section_widgets:
207
+ for widget_config in section_widgets:
208
+ if widget_index < len(self.widgets):
209
+ widget = self.widgets[widget_index]
210
+ widget_lines = widget.render()
211
+
212
+ for widget_line in widget_lines:
213
+ clean_line = widget_line.strip()
214
+ if clean_line.startswith(" "):
215
+ clean_line = clean_line[2:]
216
+ padded_line = f" {clean_line}"
217
+ modal_line = f"│{self._pad_line_with_ansi(padded_line, width-2)}│"
218
+ all_lines.append(f"{border_color}{modal_line}{ColorPalette.RESET}")
219
+ widget_line_map.append(widget_index)
220
+
221
+ widget_index += 1
222
+
223
+ # Add blank line after each section (except the last one)
224
+ if section_idx < len(sections) - 1:
225
+ blank_line = f"│{' ' * (width-2)}│"
226
+ all_lines.append(f"{border_color}{blank_line}{ColorPalette.RESET}")
227
+ widget_line_map.append(-1) # Blank line, no widget
228
+
229
+ # Auto-scroll to keep focused widget visible
230
+ if self.widgets:
231
+ focused_lines = [i for i, w in enumerate(widget_line_map) if w == self.focused_widget_index]
232
+ if focused_lines:
233
+ first_line = focused_lines[0]
234
+
235
+ # When scrolling up, include section header
236
+ if first_line < self.scroll_offset:
237
+ # If focusing first widget (wrap-around), scroll to top to show header
238
+ if self.focused_widget_index == 0:
239
+ self.scroll_offset = 0
240
+ else:
241
+ # Look for section header above the focused widget
242
+ section_header_line = first_line
243
+ for i in range(first_line - 1, -1, -1):
244
+ if widget_line_map[i] == -1: # Header or blank line
245
+ section_header_line = i
246
+ else:
247
+ break # Found another widget, stop
248
+ self.scroll_offset = section_header_line
249
+ elif first_line >= self.scroll_offset + self.visible_height:
250
+ self.scroll_offset = first_line - self.visible_height + 1
251
+
252
+ # Apply scroll offset and return visible lines
253
+ total_lines = len(all_lines)
254
+ end_offset = min(self.scroll_offset + self.visible_height, total_lines)
255
+ visible_lines = all_lines[self.scroll_offset:end_offset]
256
+
257
+ # Add scroll indicator if needed
258
+ if total_lines > self.visible_height:
259
+ scroll_info = f" [{self.scroll_offset + 1}-{end_offset}/{total_lines}] "
260
+ if self.scroll_offset > 0:
261
+ scroll_info = f"↑{scroll_info}"
262
+ if end_offset < total_lines:
263
+ scroll_info = f"{scroll_info}↓"
264
+ indicator_line = f"│{scroll_info.center(width-2)}│"
265
+ visible_lines.append(f"{ColorPalette.DIM}{indicator_line}{ColorPalette.RESET}")
266
+
267
+ return visible_lines
268
+
269
+ async def _animate_entrance(self, lines: List[str]):
270
+ """Render modal cleanly without stacking animation.
271
+
272
+ Args:
273
+ lines: Modal lines to render.
274
+ """
275
+ try:
276
+ # Single clean render without animation to prevent stacking
277
+ await self._render_modal_lines(lines)
278
+ except Exception as e:
279
+ logger.error(f"Error rendering modal: {e}")
280
+ # Single fallback render only
281
+ await self._render_modal_lines(lines)
282
+
283
+ async def _render_modal_lines(self, lines: List[str]):
284
+ """Render modal lines using TRUE overlay system (no chat pipeline).
285
+
286
+ Args:
287
+ lines: Lines to render.
288
+ """
289
+ try:
290
+ # FIXED: Use overlay rendering system instead of chat pipeline
291
+ # This completely bypasses write_message() and conversation buffers
292
+
293
+ # Create modal layout configuration
294
+ content_width = max(len(line) for line in lines) if lines else 80
295
+ # Constrain to terminal width, leaving space for borders
296
+ terminal_width = getattr(self.terminal_renderer.terminal_state, 'width', 80) if self.terminal_renderer else 80
297
+ width = min(content_width + 4, terminal_width - 2) # Add padding but leave space for borders
298
+ height = len(lines)
299
+ layout = ModalLayout(
300
+ width=width,
301
+ height=height + 2, # Add border space
302
+ center_horizontal=True,
303
+ center_vertical=True,
304
+ padding=2,
305
+ border_style="box"
306
+ )
307
+
308
+ # Prepare modal display with state isolation
309
+ if self.state_manager:
310
+ prepare_result = self.state_manager.prepare_modal_display(layout, ModalDisplayMode.OVERLAY)
311
+ if not prepare_result:
312
+ logger.error("Failed to prepare modal display")
313
+ return
314
+
315
+ # Render modal content using direct terminal output (bypassing chat)
316
+ render_result = self.state_manager.render_modal_content(lines)
317
+ if not render_result:
318
+ logger.error("Failed to render modal content")
319
+ return
320
+
321
+ logger.info(f"Modal rendered via overlay system: {len(lines)} lines")
322
+ else:
323
+ # Fallback to basic display for testing
324
+ logger.warning("Modal overlay system not available - using fallback display")
325
+ for line in lines:
326
+ print(line)
327
+
328
+ except Exception as e:
329
+ logger.error(f"Error rendering modal via overlay system: {e}")
330
+ # Ensure state is cleaned up on error
331
+ if self.state_manager:
332
+ self.state_manager.restore_terminal_state()
333
+
334
+ def _create_widgets(self, modal_config: dict) -> List[BaseWidget]:
335
+ """Create widgets from modal configuration.
336
+
337
+ Args:
338
+ modal_config: Modal configuration dictionary.
339
+
340
+ Returns:
341
+ List of instantiated widgets.
342
+ """
343
+
344
+ widgets = []
345
+ sections = modal_config.get("sections", [])
346
+
347
+
348
+ for section_idx, section in enumerate(sections):
349
+ section_widgets = section.get("widgets", [])
350
+
351
+ for widget_idx, widget_config in enumerate(section_widgets):
352
+ try:
353
+ widget = self._create_widget(widget_config)
354
+ widgets.append(widget)
355
+ except Exception as e:
356
+ logger.error(f"FAILED to create widget {widget_idx} in section {section_idx}: {e}")
357
+ logger.error(f"Widget config that failed: {widget_config}")
358
+ import traceback
359
+ logger.error(f"Full traceback: {traceback.format_exc()}")
360
+
361
+ return widgets
362
+
363
+ def _create_widget(self, config: dict) -> BaseWidget:
364
+ """Create a single widget from configuration.
365
+
366
+ Args:
367
+ config: Widget configuration dictionary.
368
+
369
+ Returns:
370
+ Instantiated widget.
371
+
372
+ Raises:
373
+ ValueError: If widget type is unknown.
374
+ """
375
+
376
+ try:
377
+ widget_type = config["type"]
378
+ except KeyError as e:
379
+ logger.error(f"Widget config missing 'type' field: {e}")
380
+ raise ValueError(f"Widget config missing required 'type' field: {config}")
381
+
382
+ config_path = config.get("config_path", "core.ui.unknown")
383
+
384
+ # Get current value from config service if available
385
+ current_value = None
386
+ if self.config_service:
387
+ current_value = self.config_service.get(config_path)
388
+ else:
389
+ pass
390
+
391
+ # Create widget config with current value
392
+ widget_config = config.copy()
393
+ if current_value is not None:
394
+ widget_config["current_value"] = current_value
395
+
396
+
397
+ try:
398
+ if widget_type == "checkbox":
399
+ widget = CheckboxWidget(widget_config, config_path, self.config_service)
400
+ return widget
401
+ elif widget_type == "dropdown":
402
+ widget = DropdownWidget(widget_config, config_path, self.config_service)
403
+ return widget
404
+ elif widget_type == "text_input":
405
+ widget = TextInputWidget(widget_config, config_path, self.config_service)
406
+ return widget
407
+ elif widget_type == "slider":
408
+ widget = SliderWidget(widget_config, config_path, self.config_service)
409
+ return widget
410
+ elif widget_type == "label":
411
+ # Label widgets use "value" directly, not config_path
412
+ label_text = config.get("label", "")
413
+ value_text = config.get("value", "")
414
+ help_text = config.get("help", "")
415
+ widget = LabelWidget(
416
+ label=label_text,
417
+ value=value_text,
418
+ help_text=help_text,
419
+ config_path=config_path,
420
+ current_value=value_text
421
+ )
422
+ return widget
423
+ else:
424
+ error_msg = f"Unknown widget type: {widget_type}"
425
+ logger.error(f"{error_msg}")
426
+ raise ValueError(error_msg)
427
+ except Exception as e:
428
+ logger.error(f"FATAL: Widget constructor failed for type '{widget_type}': {e}")
429
+ logger.error(f"Widget config that caused failure: {widget_config}")
430
+ import traceback
431
+ logger.error(f"Full constructor traceback: {traceback.format_exc()}")
432
+ raise
433
+
434
+ def _handle_widget_navigation(self, key_press: KeyPress) -> bool:
435
+ """Handle widget focus navigation.
436
+
437
+ Args:
438
+ key_press: Key press event.
439
+
440
+ Returns:
441
+ True if navigation was handled.
442
+ """
443
+ if not self.widgets:
444
+ return False
445
+
446
+ # CRITICAL FIX: Check if focused widget is expanded before handling navigation
447
+ # If a dropdown is expanded, let it handle its own ArrowDown/ArrowUp
448
+ focused_widget = self.widgets[self.focused_widget_index]
449
+ if hasattr(focused_widget, '_expanded') and focused_widget._expanded:
450
+ # Widget is expanded - don't intercept arrow keys
451
+ if key_press.name in ["ArrowDown", "ArrowUp"]:
452
+ return False # Let widget handle its own navigation
453
+
454
+ if key_press.name == "Tab" or key_press.name == "ArrowDown":
455
+ # Move to next widget
456
+ self.widgets[self.focused_widget_index].set_focus(False)
457
+ self.focused_widget_index = (self.focused_widget_index + 1) % len(self.widgets)
458
+ self.widgets[self.focused_widget_index].set_focus(True)
459
+ return True
460
+
461
+ elif key_press.name == "ArrowUp":
462
+ # Move to previous widget
463
+ self.widgets[self.focused_widget_index].set_focus(False)
464
+ self.focused_widget_index = (self.focused_widget_index - 1) % len(self.widgets)
465
+ self.widgets[self.focused_widget_index].set_focus(True)
466
+ return True
467
+
468
+ elif key_press.name == "PageDown":
469
+ # Jump forward by visible_height widgets
470
+ self.widgets[self.focused_widget_index].set_focus(False)
471
+ self.focused_widget_index = min(self.focused_widget_index + self.visible_height, len(self.widgets) - 1)
472
+ self.widgets[self.focused_widget_index].set_focus(True)
473
+ return True
474
+
475
+ elif key_press.name == "PageUp":
476
+ # Jump backward by visible_height widgets
477
+ self.widgets[self.focused_widget_index].set_focus(False)
478
+ self.focused_widget_index = max(self.focused_widget_index - self.visible_height, 0)
479
+ self.widgets[self.focused_widget_index].set_focus(True)
480
+ return True
481
+
482
+ return False
483
+
484
+ def _handle_widget_input(self, key_press: KeyPress) -> bool:
485
+ """Route input to focused widget.
486
+
487
+ Args:
488
+ key_press: Key press event.
489
+
490
+ Returns:
491
+ True if input was handled by a widget.
492
+ """
493
+
494
+ if not self.widgets or self.focused_widget_index >= len(self.widgets):
495
+ return False
496
+
497
+ focused_widget = self.widgets[self.focused_widget_index]
498
+
499
+ result = focused_widget.handle_input(key_press)
500
+ return result
501
+
502
+ def _get_widget_values(self) -> Dict[str, Any]:
503
+ """Get all widget values for saving.
504
+
505
+ Returns:
506
+ Dictionary mapping config paths to values.
507
+ """
508
+ values = {}
509
+ for widget in self.widgets:
510
+ if widget.has_pending_changes():
511
+ values[widget.config_path] = widget.get_pending_value()
512
+ return values
513
+
514
+ def _reset_widget_focus(self):
515
+ """Reset widget focus to first widget."""
516
+ if self.widgets:
517
+ for widget in self.widgets:
518
+ widget.set_focus(False)
519
+ self.focused_widget_index = 0
520
+ self.widgets[0].set_focus(True)
521
+
522
+ def _create_gradient_header(self, title: str) -> str:
523
+ """Create a gradient header text with bold white and cyan-blue gradient.
524
+
525
+ Args:
526
+ title: Section title text.
527
+
528
+ Returns:
529
+ Formatted title with gradient effect.
530
+ """
531
+ if not title:
532
+ return ""
533
+
534
+ # Make section headers slightly brighter than normal text
535
+ return f"{ColorPalette.BRIGHT}{title}{ColorPalette.RESET}"
536
+
537
+ def _strip_ansi(self, text: str) -> str:
538
+ """Remove ANSI escape codes from text.
539
+
540
+ Args:
541
+ text: Text with potential ANSI codes.
542
+
543
+ Returns:
544
+ Text with ANSI codes removed.
545
+ """
546
+ return re.sub(r'\033\[[0-9;]*m', '', text)
547
+
548
+ def _pad_line_with_ansi(self, line: str, target_width: int) -> str:
549
+ """Pad line to target width, accounting for ANSI escape codes.
550
+
551
+ Args:
552
+ line: Line that may contain ANSI codes.
553
+ target_width: Target visible width.
554
+
555
+ Returns:
556
+ Line padded to target visible width.
557
+ """
558
+ visible_length = len(self._strip_ansi(line))
559
+ padding_needed = max(0, target_width - visible_length)
560
+ return line + ' ' * padding_needed
561
+
562
+ async def _handle_modal_input(self, ui_config: UIConfig) -> Dict[str, Any]:
563
+ """Handle modal input with persistent event loop for widget interaction.
564
+
565
+ Args:
566
+ ui_config: Modal configuration.
567
+
568
+ Returns:
569
+ Modal completion result when user exits.
570
+ """
571
+ # Store ui_config for refresh operations
572
+ self.current_ui_config = ui_config
573
+
574
+ # Modal is now active and waiting for input
575
+ # Input handling happens through input_handler._handle_modal_keypress()
576
+ # which calls our widget methods and refreshes display
577
+
578
+
579
+ # The modal stays open until input_handler calls one of:
580
+ # - _exit_modal_mode() (Escape key)
581
+ # - _save_and_exit_modal() (Enter key or save action)
582
+
583
+ # This method completes when the modal is closed externally
584
+ # Return success with widget information
585
+ return {
586
+ "success": True,
587
+ "action": "modal_interactive",
588
+ "widgets_enabled": True,
589
+ "widget_count": len(self.widgets),
590
+ "widgets_created": [w.__class__.__name__ for w in self.widgets]
591
+ }