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,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
|