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
|
@@ -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
|
+
}
|