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,369 @@
1
+ """Configuration widget definitions for modal UI."""
2
+
3
+ from typing import Dict, Any, List
4
+ import logging
5
+ import importlib
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class ConfigWidgetDefinitions:
13
+ """Defines which config values get which widgets in the modal."""
14
+
15
+ @staticmethod
16
+ def get_available_plugins() -> List[Dict[str, Any]]:
17
+ """Dynamically discover available plugins for configuration.
18
+
19
+ Scans the plugins directory for *_plugin.py files and extracts
20
+ metadata from each plugin class.
21
+
22
+ Returns:
23
+ List of plugin widget dictionaries.
24
+ """
25
+ plugins = []
26
+
27
+ # Find plugins directory
28
+ plugins_dir = Path(__file__).parent.parent.parent / "plugins"
29
+ if not plugins_dir.exists():
30
+ logger.warning(f"Plugins directory not found: {plugins_dir}")
31
+ return plugins
32
+
33
+ # Scan for plugin files
34
+ for plugin_file in sorted(plugins_dir.glob("*_plugin.py")):
35
+ try:
36
+ module_name = plugin_file.stem # e.g., "tmux_plugin"
37
+ plugin_id = module_name.replace("_plugin", "") # e.g., "tmux"
38
+
39
+ # Try to import and get metadata
40
+ try:
41
+ module = importlib.import_module(f"plugins.{module_name}")
42
+
43
+ # Find the plugin class (ends with "Plugin")
44
+ plugin_class = None
45
+ for name in dir(module):
46
+ obj = getattr(module, name)
47
+ if isinstance(obj, type) and name.endswith("Plugin") and name != "Plugin":
48
+ plugin_class = obj
49
+ break
50
+
51
+ if plugin_class:
52
+ # Get name and description from class attributes
53
+ instance_name = getattr(plugin_class, 'name', None)
54
+ if instance_name is None:
55
+ # Try to get from a temporary instance or use default
56
+ instance_name = plugin_id.replace("_", " ").title()
57
+
58
+ description = getattr(plugin_class, 'description', None)
59
+ if description is None:
60
+ description = f"{instance_name} plugin"
61
+
62
+ # Use class-level name/description or instance defaults
63
+ display_name = instance_name if isinstance(instance_name, str) else plugin_id.replace("_", " ").title()
64
+
65
+ plugins.append({
66
+ "type": "checkbox",
67
+ "label": display_name.replace("_", " ").title() if display_name == plugin_id else display_name,
68
+ "config_path": f"plugins.{plugin_id}.enabled",
69
+ "help": description if isinstance(description, str) else f"{display_name} plugin"
70
+ })
71
+ logger.debug(f"Discovered plugin: {plugin_id}")
72
+
73
+ except ImportError as e:
74
+ logger.debug(f"Could not import plugin {module_name}: {e}")
75
+ # Still add it with basic info
76
+ plugins.append({
77
+ "type": "checkbox",
78
+ "label": plugin_id.replace("_", " ").title(),
79
+ "config_path": f"plugins.{plugin_id}.enabled",
80
+ "help": f"{plugin_id.replace('_', ' ').title()} plugin"
81
+ })
82
+
83
+ except Exception as e:
84
+ logger.error(f"Error processing plugin file {plugin_file}: {e}")
85
+
86
+ logger.info(f"Discovered {len(plugins)} plugins for configuration")
87
+ return plugins
88
+
89
+ @staticmethod
90
+ def get_plugin_config_sections() -> List[Dict[str, Any]]:
91
+ """Dynamically collect config widget sections from plugins.
92
+
93
+ Looks for get_config_widgets() method on each plugin class.
94
+
95
+ Returns:
96
+ List of section definitions from plugins.
97
+ """
98
+ sections = []
99
+
100
+ # Known plugin modules and their class names
101
+ plugin_modules = {
102
+ "plugins.enhanced_input_plugin": "EnhancedInputPlugin",
103
+ "plugins.hook_monitoring_plugin": "HookMonitoringPlugin",
104
+ "plugins.query_enhancer_plugin": "QueryEnhancerPlugin",
105
+ "plugins.workflow_enforcement_plugin": "WorkflowEnforcementPlugin",
106
+ "plugins.system_commands_plugin": "SystemCommandsPlugin",
107
+ }
108
+
109
+ for module_name, class_name in plugin_modules.items():
110
+ try:
111
+ module = importlib.import_module(module_name)
112
+ plugin_class = getattr(module, class_name, None)
113
+
114
+ if plugin_class and hasattr(plugin_class, "get_config_widgets"):
115
+ widget_section = plugin_class.get_config_widgets()
116
+ if widget_section:
117
+ sections.append(widget_section)
118
+ logger.debug(f"Loaded config widgets from {class_name}")
119
+ except Exception as e:
120
+ logger.debug(f"Could not load config widgets from {module_name}: {e}")
121
+
122
+ return sections
123
+
124
+ @staticmethod
125
+ def get_config_modal_definition() -> Dict[str, Any]:
126
+ """Get the complete modal definition for /config command.
127
+
128
+ Returns:
129
+ Dictionary defining the modal layout and widgets.
130
+ """
131
+ # Get plugin widgets
132
+ plugin_widgets = ConfigWidgetDefinitions.get_available_plugins()
133
+
134
+ return {
135
+ "title": "System Configuration",
136
+ "footer": "↑↓/PgUp/PgDn navigate • Enter toggle • Ctrl+S save • Esc cancel",
137
+ "width": 120, # 80% of screen width
138
+ "height": 40,
139
+ "sections": [
140
+ {
141
+ "title": "Terminal Settings",
142
+ "widgets": [
143
+ {
144
+ "type": "slider",
145
+ "label": "Render FPS",
146
+ "config_path": "terminal.render_fps",
147
+ "min_value": 1,
148
+ "max_value": 60,
149
+ "step": 1,
150
+ "help": "Terminal refresh rate (1-60 FPS)"
151
+ },
152
+ {
153
+ "type": "slider",
154
+ "label": "Status Lines",
155
+ "config_path": "terminal.status_lines",
156
+ "min_value": 1,
157
+ "max_value": 10,
158
+ "step": 1,
159
+ "help": "Number of status lines to display"
160
+ },
161
+ {
162
+ "type": "dropdown",
163
+ "label": "Thinking Effect",
164
+ "config_path": "terminal.thinking_effect",
165
+ "options": ["shimmer", "pulse", "wave", "none"],
166
+ "help": "Visual effect for thinking animations"
167
+ },
168
+ {
169
+ "type": "slider",
170
+ "label": "Shimmer Speed",
171
+ "config_path": "terminal.shimmer_speed",
172
+ "min_value": 1,
173
+ "max_value": 10,
174
+ "step": 1,
175
+ "help": "Speed of shimmer animation effect"
176
+ },
177
+ {
178
+ "type": "checkbox",
179
+ "label": "Enable Render Cache",
180
+ "config_path": "terminal.render_cache_enabled",
181
+ "help": "Cache renders to reduce unnecessary terminal I/O when idle"
182
+ }
183
+ ]
184
+ },
185
+ {
186
+ "title": "Input Settings",
187
+ "widgets": [
188
+ {
189
+ "type": "checkbox",
190
+ "label": "Ctrl+C Exit",
191
+ "config_path": "input.ctrl_c_exit",
192
+ "help": "Allow Ctrl+C to exit application"
193
+ },
194
+ {
195
+ "type": "checkbox",
196
+ "label": "Backspace Enabled",
197
+ "config_path": "input.backspace_enabled",
198
+ "help": "Enable backspace key for text editing"
199
+ },
200
+ {
201
+ "type": "slider",
202
+ "label": "History Limit",
203
+ "config_path": "input.history_limit",
204
+ "min_value": 10,
205
+ "max_value": 1000,
206
+ "step": 10,
207
+ "help": "Maximum number of history entries"
208
+ }
209
+ ]
210
+ },
211
+ {
212
+ "title": "LLM Settings",
213
+ "widgets": [
214
+ {
215
+ "type": "text_input",
216
+ "label": "API URL",
217
+ "config_path": "core.llm.api_url",
218
+ "placeholder": "http://localhost:1234",
219
+ "help": "LLM API endpoint URL"
220
+ },
221
+ {
222
+ "type": "text_input",
223
+ "label": "Model",
224
+ "config_path": "core.llm.model",
225
+ "placeholder": "qwen/qwen3-4b",
226
+ "help": "LLM model identifier"
227
+ },
228
+ {
229
+ "type": "slider",
230
+ "label": "Temperature",
231
+ "config_path": "core.llm.temperature",
232
+ "min_value": 0.0,
233
+ "max_value": 2.0,
234
+ "step": 0.1,
235
+ "help": "Creativity/randomness of responses (0.0-2.0)"
236
+ },
237
+ {
238
+ "type": "slider",
239
+ "label": "Max History",
240
+ "config_path": "core.llm.max_history",
241
+ "min_value": 10,
242
+ "max_value": 200,
243
+ "step": 10,
244
+ "help": "Maximum conversation history entries"
245
+ }
246
+ ]
247
+ },
248
+ {
249
+ "title": "Application Settings",
250
+ "widgets": [
251
+ {
252
+ "type": "text_input",
253
+ "label": "Application Name",
254
+ "config_path": "application.name",
255
+ "placeholder": "Kollabor CLI",
256
+ "help": "Display name for the application"
257
+ },
258
+ {
259
+ "type": "text_input",
260
+ "label": "Version",
261
+ "config_path": "application.version",
262
+ "placeholder": "1.0.0",
263
+ "help": "Current application version"
264
+ }
265
+ ]
266
+ },
267
+ {
268
+ "title": "Plugin Settings",
269
+ "widgets": plugin_widgets
270
+ },
271
+ # Plugin config sections are loaded dynamically below
272
+ ] + ConfigWidgetDefinitions.get_plugin_config_sections(),
273
+ "actions": [
274
+ {
275
+ "key": "Ctrl+S",
276
+ "label": "Save",
277
+ "action": "save",
278
+ "style": "primary"
279
+ },
280
+ {
281
+ "key": "Escape",
282
+ "label": "Cancel",
283
+ "action": "cancel",
284
+ "style": "secondary"
285
+ }
286
+ ]
287
+ }
288
+
289
+ @staticmethod
290
+ def create_widgets_from_definition(config_service, definition: Dict[str, Any]) -> List[Any]:
291
+ """Create widget instances from modal definition.
292
+
293
+ Args:
294
+ config_service: ConfigService for reading current values.
295
+ definition: Modal definition dictionary.
296
+
297
+ Returns:
298
+ List of instantiated widgets.
299
+ """
300
+ widgets = []
301
+
302
+ try:
303
+ from .widgets.checkbox import CheckboxWidget
304
+ from .widgets.dropdown import DropdownWidget
305
+ from .widgets.text_input import TextInputWidget
306
+ from .widgets.slider import SliderWidget
307
+ from .widgets.label import LabelWidget
308
+
309
+ widget_classes = {
310
+ "checkbox": CheckboxWidget,
311
+ "dropdown": DropdownWidget,
312
+ "text_input": TextInputWidget,
313
+ "slider": SliderWidget,
314
+ "label": LabelWidget
315
+ }
316
+
317
+ for section in definition.get("sections", []):
318
+ for widget_def in section.get("widgets", []):
319
+ widget_type = widget_def["type"]
320
+ widget_class = widget_classes.get(widget_type)
321
+
322
+ if not widget_class:
323
+ logger.error(f"Unknown widget type: {widget_type}")
324
+ continue
325
+
326
+ # Get current value from config (optional for labels)
327
+ config_path = widget_def.get("config_path", "")
328
+ if config_path:
329
+ current_value = config_service.get(config_path)
330
+ else:
331
+ # For label widgets, use the "value" field directly
332
+ current_value = widget_def.get("value", "")
333
+
334
+ # Create widget with configuration
335
+ widget = widget_class(
336
+ label=widget_def["label"],
337
+ config_path=config_path,
338
+ help_text=widget_def.get("help", ""),
339
+ current_value=current_value,
340
+ **{k: v for k, v in widget_def.items()
341
+ if k not in ["type", "label", "config_path", "help", "value"]}
342
+ )
343
+
344
+ widgets.append(widget)
345
+ logger.debug(f"Created {widget_type} widget for {config_path}")
346
+
347
+ except Exception as e:
348
+ logger.error(f"Error creating widgets from definition: {e}")
349
+
350
+ logger.info(f"Created {len(widgets)} widgets from definition")
351
+ return widgets
352
+
353
+ @staticmethod
354
+ def get_widget_navigation_info() -> Dict[str, str]:
355
+ """Get navigation key information for modal help.
356
+
357
+ Returns:
358
+ Dictionary mapping keys to their descriptions.
359
+ """
360
+ return {
361
+ "up_down": "Navigate between widgets",
362
+ "left_right": "Adjust slider values",
363
+ "enter": "Toggle checkbox",
364
+ "space": "Toggle checkbox",
365
+ "tab": "Next widget",
366
+ "shift_tab": "Previous widget",
367
+ "ctrl_s": "Save all changes",
368
+ "escape": "Cancel and exit"
369
+ }
@@ -0,0 +1,276 @@
1
+ """Live modal renderer for streaming/updating content.
2
+
3
+ Uses ModalStateManager for proper terminal state isolation,
4
+ with a refresh loop for continuously updating content.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ from typing import List, Callable, Optional, Dict, Any, Awaitable, Union
10
+ from dataclasses import dataclass
11
+
12
+ from ..io.terminal_state import TerminalState
13
+ from ..io.visual_effects import ColorPalette
14
+ from ..io.key_parser import KeyPress
15
+ from .modal_state_manager import ModalStateManager, ModalLayout, ModalDisplayMode
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class LiveModalConfig:
22
+ """Configuration for live modal display."""
23
+ title: str = "Live View"
24
+ footer: str = "Esc to exit"
25
+ refresh_rate: float = 0.5 # Seconds between refreshes
26
+ show_border: bool = True
27
+ passthrough_input: bool = False # Forward input to external process
28
+
29
+
30
+ class LiveModalRenderer:
31
+ """Renders live-updating content using ModalStateManager.
32
+
33
+ Uses the same infrastructure as config/status modals for proper
34
+ terminal state isolation, with an added refresh loop for live content.
35
+ """
36
+
37
+ def __init__(self, terminal_state: TerminalState):
38
+ """Initialize live modal renderer.
39
+
40
+ Args:
41
+ terminal_state: TerminalState for terminal control.
42
+ """
43
+ self.terminal_state = terminal_state
44
+ self.state_manager = ModalStateManager(terminal_state)
45
+ self.modal_active = False
46
+ self.config: Optional[LiveModalConfig] = None
47
+ self._refresh_task: Optional[asyncio.Task] = None
48
+ self._input_callback: Optional[Callable[[KeyPress], Awaitable[bool]]] = None
49
+ self._content_generator: Optional[Callable[[], Union[List[str], Awaitable[List[str]]]]] = None
50
+ self._should_exit = False
51
+
52
+ def start_live_modal(
53
+ self,
54
+ content_generator: Callable[[], Union[List[str], Awaitable[List[str]]]],
55
+ config: Optional[LiveModalConfig] = None,
56
+ input_callback: Optional[Callable[[KeyPress], Awaitable[bool]]] = None
57
+ ) -> bool:
58
+ """Start live modal (non-blocking).
59
+
60
+ Args:
61
+ content_generator: Function that returns current content lines.
62
+ config: Modal configuration.
63
+ input_callback: Optional callback for input handling.
64
+
65
+ Returns:
66
+ True if modal started successfully.
67
+ """
68
+ try:
69
+ self.config = config or LiveModalConfig()
70
+ self._content_generator = content_generator
71
+ self._input_callback = input_callback
72
+ self._should_exit = False
73
+
74
+ # Get terminal size for layout
75
+ width, height = self.terminal_state.get_size()
76
+
77
+ # Create layout for fullscreen modal
78
+ layout = ModalLayout(
79
+ width=width - 4, # Leave margin
80
+ height=height - 2,
81
+ start_row=1,
82
+ start_col=2,
83
+ center_horizontal=True,
84
+ center_vertical=False,
85
+ padding=1,
86
+ border_style="box"
87
+ )
88
+
89
+ # Use ModalStateManager to prepare display (enters alt buffer)
90
+ success = self.state_manager.prepare_modal_display(
91
+ layout,
92
+ ModalDisplayMode.FULLSCREEN
93
+ )
94
+
95
+ if not success:
96
+ logger.error("Failed to prepare modal display")
97
+ return False
98
+
99
+ self.modal_active = True
100
+ logger.info(f"Live modal started: {self.config.title}")
101
+
102
+ # Start refresh loop as a background task
103
+ self._refresh_task = asyncio.create_task(self._refresh_loop())
104
+
105
+ return True
106
+
107
+ except Exception as e:
108
+ logger.error(f"Error starting live modal: {e}")
109
+ return False
110
+
111
+ async def _refresh_loop(self):
112
+ """Main refresh loop - updates display continuously."""
113
+ try:
114
+ while self.modal_active and not self._should_exit:
115
+ # Get fresh content
116
+ content = await self._get_content()
117
+
118
+ # Render frame using state manager
119
+ self._render_frame(content)
120
+
121
+ # Sleep for refresh rate
122
+ await asyncio.sleep(self.config.refresh_rate)
123
+
124
+ except asyncio.CancelledError:
125
+ logger.debug("Refresh loop cancelled")
126
+ except Exception as e:
127
+ logger.error(f"Error in refresh loop: {e}")
128
+
129
+ async def _get_content(self) -> List[str]:
130
+ """Get content from generator (handles sync/async)."""
131
+ try:
132
+ if asyncio.iscoroutinefunction(self._content_generator):
133
+ return await self._content_generator()
134
+ else:
135
+ return self._content_generator()
136
+ except Exception as e:
137
+ logger.error(f"Error getting content: {e}")
138
+ return [f"Error: {e}"]
139
+
140
+ def _render_frame(self, content_lines: List[str]):
141
+ """Render a single frame using ModalStateManager."""
142
+ try:
143
+ if not self.state_manager.current_layout:
144
+ return
145
+
146
+ layout = self.state_manager.current_layout
147
+
148
+ # Build modal lines with border
149
+ if self.config.show_border:
150
+ modal_lines = self._build_bordered_content(content_lines, layout.width, layout.height)
151
+ else:
152
+ modal_lines = content_lines[:layout.height]
153
+
154
+ # Use state manager to render
155
+ self.state_manager.render_modal_content(modal_lines)
156
+
157
+ except Exception as e:
158
+ logger.error(f"Error rendering frame: {e}")
159
+
160
+ def _build_bordered_content(self, content_lines: List[str], width: int, height: int) -> List[str]:
161
+ """Build content with border and title/footer."""
162
+ border_color = ColorPalette.GREY
163
+ title_color = ColorPalette.WHITE
164
+ reset = ColorPalette.RESET
165
+
166
+ lines = []
167
+ inner_width = width - 2 # Account for borders
168
+
169
+ # Top border with title
170
+ title = self.config.title
171
+ title_padding = max(0, inner_width - len(title) - 2)
172
+ left_pad = title_padding // 2
173
+ right_pad = title_padding - left_pad
174
+ top_border = f"{border_color}╭{'─' * left_pad} {title_color}{title}{reset}{border_color} {'─' * right_pad}╮{reset}"
175
+ lines.append(top_border)
176
+
177
+ # Content area (height - 2 for top/bottom borders)
178
+ content_height = height - 2
179
+ for i in range(content_height):
180
+ if i < len(content_lines):
181
+ line = content_lines[i]
182
+ # Strip ANSI for length calculation
183
+ visible_len = len(self._strip_ansi(line))
184
+ if visible_len > inner_width:
185
+ # Truncate line
186
+ line = line[:inner_width - 3] + "..."
187
+ visible_len = inner_width
188
+ padding = max(0, inner_width - visible_len)
189
+ content_line = f"{border_color}│{reset}{line}{' ' * padding}{border_color}│{reset}"
190
+ else:
191
+ # Empty line
192
+ content_line = f"{border_color}│{' ' * inner_width}│{reset}"
193
+ lines.append(content_line)
194
+
195
+ # Bottom border with footer
196
+ footer = self.config.footer
197
+ footer_padding = max(0, inner_width - len(footer) - 2)
198
+ left_pad = footer_padding // 2
199
+ right_pad = footer_padding - left_pad
200
+ bottom_border = f"{border_color}╰{'─' * left_pad} {footer} {'─' * right_pad}╯{reset}"
201
+ lines.append(bottom_border)
202
+
203
+ return lines
204
+
205
+ def _strip_ansi(self, text: str) -> str:
206
+ """Remove ANSI escape codes from text."""
207
+ import re
208
+ return re.sub(r'\033\[[0-9;]*m', '', text)
209
+
210
+ async def handle_input(self, key_press: KeyPress) -> bool:
211
+ """Handle input during live modal.
212
+
213
+ Args:
214
+ key_press: Key press event.
215
+
216
+ Returns:
217
+ True if modal should close.
218
+ """
219
+ try:
220
+ # Always handle Escape to exit
221
+ if key_press.name == "Escape":
222
+ self._should_exit = True
223
+ return True
224
+
225
+ # Ctrl+C also exits
226
+ if key_press.char and ord(key_press.char) == 3:
227
+ self._should_exit = True
228
+ return True
229
+
230
+ # If passthrough enabled and callback provided, forward input
231
+ if self.config.passthrough_input and self._input_callback:
232
+ should_close = await self._input_callback(key_press)
233
+ if should_close:
234
+ self._should_exit = True
235
+ return should_close
236
+
237
+ return False
238
+
239
+ except Exception as e:
240
+ logger.error(f"Error handling live modal input: {e}")
241
+ return False
242
+
243
+ def request_exit(self):
244
+ """Request the modal to exit (thread-safe)."""
245
+ self._should_exit = True
246
+
247
+ async def close_modal(self):
248
+ """Close the live modal and restore terminal."""
249
+ try:
250
+ if not self.modal_active:
251
+ return
252
+
253
+ self.modal_active = False
254
+ self._should_exit = True
255
+
256
+ # Cancel refresh task if running
257
+ if self._refresh_task and not self._refresh_task.done():
258
+ self._refresh_task.cancel()
259
+ try:
260
+ await self._refresh_task
261
+ except asyncio.CancelledError:
262
+ pass
263
+
264
+ # Use state manager to restore terminal (exits alt buffer)
265
+ self.state_manager.restore_terminal_state()
266
+
267
+ logger.info("Live modal closed")
268
+
269
+ except Exception as e:
270
+ logger.error(f"Error closing live modal: {e}")
271
+ # Force restore on error
272
+ self.state_manager.restore_terminal_state()
273
+
274
+ def is_active(self) -> bool:
275
+ """Check if live modal is currently active."""
276
+ return self.modal_active