kollabor 0.4.9__py3-none-any.whl → 0.4.15__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.
- agents/__init__.py +2 -0
- agents/coder/__init__.py +0 -0
- agents/coder/agent.json +4 -0
- agents/coder/api-integration.md +2150 -0
- agents/coder/cli-pretty.md +765 -0
- agents/coder/code-review.md +1092 -0
- agents/coder/database-design.md +1525 -0
- agents/coder/debugging.md +1102 -0
- agents/coder/dependency-management.md +1397 -0
- agents/coder/git-workflow.md +1099 -0
- agents/coder/refactoring.md +1454 -0
- agents/coder/security-hardening.md +1732 -0
- agents/coder/system_prompt.md +1448 -0
- agents/coder/tdd.md +1367 -0
- agents/creative-writer/__init__.py +0 -0
- agents/creative-writer/agent.json +4 -0
- agents/creative-writer/character-development.md +1852 -0
- agents/creative-writer/dialogue-craft.md +1122 -0
- agents/creative-writer/plot-structure.md +1073 -0
- agents/creative-writer/revision-editing.md +1484 -0
- agents/creative-writer/system_prompt.md +690 -0
- agents/creative-writer/worldbuilding.md +2049 -0
- agents/data-analyst/__init__.py +30 -0
- agents/data-analyst/agent.json +4 -0
- agents/data-analyst/data-visualization.md +992 -0
- agents/data-analyst/exploratory-data-analysis.md +1110 -0
- agents/data-analyst/pandas-data-manipulation.md +1081 -0
- agents/data-analyst/sql-query-optimization.md +881 -0
- agents/data-analyst/statistical-analysis.md +1118 -0
- agents/data-analyst/system_prompt.md +928 -0
- agents/default/__init__.py +0 -0
- agents/default/agent.json +4 -0
- agents/default/dead-code.md +794 -0
- agents/default/explore-agent-system.md +585 -0
- agents/default/system_prompt.md +1448 -0
- agents/kollabor/__init__.py +0 -0
- agents/kollabor/analyze-plugin-lifecycle.md +175 -0
- agents/kollabor/analyze-terminal-rendering.md +388 -0
- agents/kollabor/code-review.md +1092 -0
- agents/kollabor/debug-mcp-integration.md +521 -0
- agents/kollabor/debug-plugin-hooks.md +547 -0
- agents/kollabor/debugging.md +1102 -0
- agents/kollabor/dependency-management.md +1397 -0
- agents/kollabor/git-workflow.md +1099 -0
- agents/kollabor/inspect-llm-conversation.md +148 -0
- agents/kollabor/monitor-event-bus.md +558 -0
- agents/kollabor/profile-performance.md +576 -0
- agents/kollabor/refactoring.md +1454 -0
- agents/kollabor/system_prompt copy.md +1448 -0
- agents/kollabor/system_prompt.md +757 -0
- agents/kollabor/trace-command-execution.md +178 -0
- agents/kollabor/validate-config.md +879 -0
- agents/research/__init__.py +0 -0
- agents/research/agent.json +4 -0
- agents/research/architecture-mapping.md +1099 -0
- agents/research/codebase-analysis.md +1077 -0
- agents/research/dependency-audit.md +1027 -0
- agents/research/performance-profiling.md +1047 -0
- agents/research/security-review.md +1359 -0
- agents/research/system_prompt.md +492 -0
- agents/technical-writer/__init__.py +0 -0
- agents/technical-writer/agent.json +4 -0
- agents/technical-writer/api-documentation.md +2328 -0
- agents/technical-writer/changelog-management.md +1181 -0
- agents/technical-writer/readme-writing.md +1360 -0
- agents/technical-writer/style-guide.md +1410 -0
- agents/technical-writer/system_prompt.md +653 -0
- agents/technical-writer/tutorial-creation.md +1448 -0
- core/__init__.py +0 -2
- core/application.py +343 -88
- core/cli.py +229 -10
- core/commands/menu_renderer.py +463 -59
- core/commands/registry.py +14 -9
- core/commands/system_commands.py +2461 -14
- core/config/loader.py +151 -37
- core/config/service.py +18 -6
- core/events/bus.py +29 -9
- core/events/executor.py +205 -75
- core/events/models.py +27 -8
- core/fullscreen/command_integration.py +20 -24
- core/fullscreen/components/__init__.py +10 -1
- core/fullscreen/components/matrix_components.py +1 -2
- core/fullscreen/components/space_shooter_components.py +654 -0
- core/fullscreen/plugin.py +5 -0
- core/fullscreen/renderer.py +52 -13
- core/fullscreen/session.py +52 -15
- core/io/__init__.py +29 -5
- core/io/buffer_manager.py +6 -1
- core/io/config_status_view.py +7 -29
- core/io/core_status_views.py +267 -347
- core/io/input/__init__.py +25 -0
- core/io/input/command_mode_handler.py +711 -0
- core/io/input/display_controller.py +128 -0
- core/io/input/hook_registrar.py +286 -0
- core/io/input/input_loop_manager.py +421 -0
- core/io/input/key_press_handler.py +502 -0
- core/io/input/modal_controller.py +1011 -0
- core/io/input/paste_processor.py +339 -0
- core/io/input/status_modal_renderer.py +184 -0
- core/io/input_errors.py +5 -1
- core/io/input_handler.py +211 -2452
- core/io/key_parser.py +7 -0
- core/io/layout.py +15 -3
- core/io/message_coordinator.py +111 -2
- core/io/message_renderer.py +129 -4
- core/io/status_renderer.py +147 -607
- core/io/terminal_renderer.py +97 -51
- core/io/terminal_state.py +21 -4
- core/io/visual_effects.py +816 -165
- core/llm/agent_manager.py +1063 -0
- core/llm/api_adapters/__init__.py +44 -0
- core/llm/api_adapters/anthropic_adapter.py +432 -0
- core/llm/api_adapters/base.py +241 -0
- core/llm/api_adapters/openai_adapter.py +326 -0
- core/llm/api_communication_service.py +167 -113
- core/llm/conversation_logger.py +322 -16
- core/llm/conversation_manager.py +556 -30
- core/llm/file_operations_executor.py +84 -32
- core/llm/llm_service.py +934 -103
- core/llm/mcp_integration.py +541 -57
- core/llm/message_display_service.py +135 -18
- core/llm/plugin_sdk.py +1 -2
- core/llm/profile_manager.py +1183 -0
- core/llm/response_parser.py +274 -56
- core/llm/response_processor.py +16 -3
- core/llm/tool_executor.py +6 -1
- core/logging/__init__.py +2 -0
- core/logging/setup.py +34 -6
- core/models/resume.py +54 -0
- core/plugins/__init__.py +4 -2
- core/plugins/base.py +127 -0
- core/plugins/collector.py +23 -161
- core/plugins/discovery.py +37 -3
- core/plugins/factory.py +6 -12
- core/plugins/registry.py +5 -17
- core/ui/config_widgets.py +128 -28
- core/ui/live_modal_renderer.py +2 -1
- core/ui/modal_actions.py +5 -0
- core/ui/modal_overlay_renderer.py +0 -60
- core/ui/modal_renderer.py +268 -7
- core/ui/modal_state_manager.py +29 -4
- core/ui/widgets/base_widget.py +7 -0
- core/updates/__init__.py +10 -0
- core/updates/version_check_service.py +348 -0
- core/updates/version_comparator.py +103 -0
- core/utils/config_utils.py +685 -526
- core/utils/plugin_utils.py +1 -1
- core/utils/session_naming.py +111 -0
- fonts/LICENSE +21 -0
- fonts/README.md +46 -0
- fonts/SymbolsNerdFont-Regular.ttf +0 -0
- fonts/SymbolsNerdFontMono-Regular.ttf +0 -0
- fonts/__init__.py +44 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/METADATA +54 -4
- kollabor-0.4.15.dist-info/RECORD +228 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/top_level.txt +2 -0
- plugins/agent_orchestrator/__init__.py +39 -0
- plugins/agent_orchestrator/activity_monitor.py +181 -0
- plugins/agent_orchestrator/file_attacher.py +77 -0
- plugins/agent_orchestrator/message_injector.py +135 -0
- plugins/agent_orchestrator/models.py +48 -0
- plugins/agent_orchestrator/orchestrator.py +403 -0
- plugins/agent_orchestrator/plugin.py +976 -0
- plugins/agent_orchestrator/xml_parser.py +191 -0
- plugins/agent_orchestrator_plugin.py +9 -0
- plugins/enhanced_input/box_styles.py +1 -0
- plugins/enhanced_input/color_engine.py +19 -4
- plugins/enhanced_input/config.py +2 -2
- plugins/enhanced_input_plugin.py +61 -11
- plugins/fullscreen/__init__.py +6 -2
- plugins/fullscreen/example_plugin.py +1035 -222
- plugins/fullscreen/setup_wizard_plugin.py +592 -0
- plugins/fullscreen/space_shooter_plugin.py +131 -0
- plugins/hook_monitoring_plugin.py +436 -78
- plugins/query_enhancer_plugin.py +66 -30
- plugins/resume_conversation_plugin.py +1494 -0
- plugins/save_conversation_plugin.py +98 -32
- plugins/system_commands_plugin.py +70 -56
- plugins/tmux_plugin.py +154 -78
- plugins/workflow_enforcement_plugin.py +94 -92
- system_prompt/default.md +952 -886
- core/io/input_mode_manager.py +0 -402
- core/io/modal_interaction_handler.py +0 -315
- core/io/raw_input_processor.py +0 -946
- core/storage/__init__.py +0 -5
- core/storage/state_manager.py +0 -84
- core/ui/widget_integration.py +0 -222
- core/utils/key_reader.py +0 -171
- kollabor-0.4.9.dist-info/RECORD +0 -128
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/WHEEL +0 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/entry_points.txt +0 -0
- {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
"""Modal controller component for managing modal interactions.
|
|
2
|
+
|
|
3
|
+
This component handles all modal-related operations including:
|
|
4
|
+
- Standard modals (full-screen with widgets)
|
|
5
|
+
- Status modals (confined to status area)
|
|
6
|
+
- Live modals (continuously updating content)
|
|
7
|
+
- Modal event handling and state management
|
|
8
|
+
|
|
9
|
+
Extracted from InputHandler as part of the refactoring effort.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Dict, Any, List, Optional, Callable
|
|
15
|
+
|
|
16
|
+
from ...events.models import CommandMode, EventType
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ModalController:
|
|
22
|
+
"""Manages modal display and interaction logic.
|
|
23
|
+
|
|
24
|
+
This component coordinates between different modal types and handles
|
|
25
|
+
modal-specific input events, state transitions, and rendering.
|
|
26
|
+
|
|
27
|
+
Responsibilities:
|
|
28
|
+
- Handle modal trigger events (MODAL_TRIGGER, STATUS_MODAL_TRIGGER, LIVE_MODAL_TRIGGER)
|
|
29
|
+
- Manage modal state (command_mode, current_status_modal_config, modal_renderer)
|
|
30
|
+
- Process modal keypresses and input
|
|
31
|
+
- Coordinate modal entry/exit with proper state management
|
|
32
|
+
- Handle save confirmations and modal data persistence
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
renderer,
|
|
38
|
+
event_bus,
|
|
39
|
+
config,
|
|
40
|
+
status_modal_renderer,
|
|
41
|
+
update_display_callback: Callable,
|
|
42
|
+
exit_command_mode_callback: Callable,
|
|
43
|
+
set_command_mode_callback: Optional[Callable] = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Initialize the modal controller.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
renderer: Terminal renderer for display operations.
|
|
49
|
+
event_bus: Event bus for emitting modal events.
|
|
50
|
+
config: Configuration service.
|
|
51
|
+
status_modal_renderer: StatusModalRenderer for status area modals.
|
|
52
|
+
update_display_callback: Callback to update display (async).
|
|
53
|
+
exit_command_mode_callback: Callback to exit command mode (async).
|
|
54
|
+
set_command_mode_callback: Callback to set command_mode (syncs with parent).
|
|
55
|
+
"""
|
|
56
|
+
self.renderer = renderer
|
|
57
|
+
self.event_bus = event_bus
|
|
58
|
+
self.config = config
|
|
59
|
+
self._status_modal_renderer = status_modal_renderer
|
|
60
|
+
self._update_display = update_display_callback
|
|
61
|
+
self._exit_command_mode = exit_command_mode_callback
|
|
62
|
+
self._set_command_mode_callback = set_command_mode_callback
|
|
63
|
+
|
|
64
|
+
# Modal state
|
|
65
|
+
self._command_mode = CommandMode.NORMAL
|
|
66
|
+
self.current_status_modal_config = None
|
|
67
|
+
self.modal_renderer = None # ModalRenderer instance when active
|
|
68
|
+
self.live_modal_renderer = None # LiveModalRenderer instance when active
|
|
69
|
+
self.live_modal_content_generator = None # Content generator function
|
|
70
|
+
self.live_modal_input_callback = None # Input callback for passthrough
|
|
71
|
+
self._pending_save_confirm = False # For modal save confirmation
|
|
72
|
+
self._fullscreen_session_active = False # For fullscreen plugin sessions
|
|
73
|
+
|
|
74
|
+
logger.info("ModalController initialized")
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def command_mode(self) -> CommandMode:
|
|
78
|
+
"""Get current command mode."""
|
|
79
|
+
return self._command_mode
|
|
80
|
+
|
|
81
|
+
@command_mode.setter
|
|
82
|
+
def command_mode(self, value: CommandMode) -> None:
|
|
83
|
+
"""Set command mode and notify parent via callback."""
|
|
84
|
+
self._command_mode = value
|
|
85
|
+
if self._set_command_mode_callback:
|
|
86
|
+
self._set_command_mode_callback(value)
|
|
87
|
+
|
|
88
|
+
# ==================== EVENT HANDLERS ====================
|
|
89
|
+
|
|
90
|
+
async def _handle_modal_trigger(
|
|
91
|
+
self, event_data: Dict[str, Any], context: str = None
|
|
92
|
+
) -> Dict[str, Any]:
|
|
93
|
+
"""Handle modal trigger events to show modals.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
event_data: Event data containing modal configuration.
|
|
97
|
+
context: Hook execution context.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Dictionary with modal result.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
# Check if this is a Matrix effect trigger
|
|
104
|
+
if event_data.get("matrix_effect"):
|
|
105
|
+
logger.info(
|
|
106
|
+
"Matrix effect modal trigger received - setting modal mode for complete terminal control"
|
|
107
|
+
)
|
|
108
|
+
# Set modal mode directly for Matrix effect (no UI config needed)
|
|
109
|
+
self.command_mode = CommandMode.MODAL
|
|
110
|
+
logger.info("Command mode set to MODAL for Matrix effect")
|
|
111
|
+
return {
|
|
112
|
+
"success": True,
|
|
113
|
+
"modal_activated": True,
|
|
114
|
+
"matrix_mode": True,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# Check if this is a full-screen plugin trigger
|
|
118
|
+
if event_data.get("fullscreen_plugin"):
|
|
119
|
+
plugin_name = event_data.get("plugin_name", "unknown")
|
|
120
|
+
logger.info(
|
|
121
|
+
f"Full-screen plugin modal trigger received: {plugin_name}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Use coordinator to save state before fullscreen (handles writing_messages, etc.)
|
|
125
|
+
if hasattr(self.renderer, 'message_coordinator'):
|
|
126
|
+
self.renderer.message_coordinator.enter_alternate_buffer()
|
|
127
|
+
|
|
128
|
+
self.renderer.clear_active_area()
|
|
129
|
+
|
|
130
|
+
# Set modal mode for full-screen plugin (no UI config needed)
|
|
131
|
+
self.command_mode = CommandMode.MODAL
|
|
132
|
+
# CRITICAL FIX: Mark fullscreen session as active for input routing
|
|
133
|
+
self._fullscreen_session_active = True
|
|
134
|
+
logger.info(
|
|
135
|
+
f"Command mode set to MODAL for full-screen plugin: {plugin_name}"
|
|
136
|
+
)
|
|
137
|
+
logger.info(
|
|
138
|
+
"Fullscreen session marked as active for input routing"
|
|
139
|
+
)
|
|
140
|
+
return {
|
|
141
|
+
"success": True,
|
|
142
|
+
"modal_activated": True,
|
|
143
|
+
"fullscreen_plugin": True,
|
|
144
|
+
"plugin_name": plugin_name,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# Standard modal with UI config
|
|
148
|
+
ui_config = event_data.get("ui_config")
|
|
149
|
+
if ui_config:
|
|
150
|
+
logger.info(f"Modal trigger received: {ui_config.title}")
|
|
151
|
+
await self._enter_modal_mode(ui_config)
|
|
152
|
+
return {"success": True, "modal_activated": True}
|
|
153
|
+
else:
|
|
154
|
+
logger.warning("Modal trigger received without ui_config")
|
|
155
|
+
return {"success": False, "error": "Missing ui_config"}
|
|
156
|
+
|
|
157
|
+
except Exception as e:
|
|
158
|
+
logger.error(f"Error handling modal trigger: {e}")
|
|
159
|
+
return {"success": False, "error": str(e)}
|
|
160
|
+
|
|
161
|
+
async def _handle_modal_hide(
|
|
162
|
+
self, event_data: Dict[str, Any], context: str = None
|
|
163
|
+
) -> Dict[str, Any]:
|
|
164
|
+
"""Handle modal hide event to exit modal mode.
|
|
165
|
+
|
|
166
|
+
NOTE: This is called AFTER fullscreen renderer has already restored
|
|
167
|
+
the terminal (exited alternate buffer with \033[?1049l). We must NOT
|
|
168
|
+
call clear_active_area() here as it would clear the just-restored screen.
|
|
169
|
+
"""
|
|
170
|
+
logger.info("MODAL_HIDE event received - exiting modal mode")
|
|
171
|
+
try:
|
|
172
|
+
# Set render state flags (alternate buffer was already exited by fullscreen renderer)
|
|
173
|
+
self.renderer.writing_messages = False
|
|
174
|
+
# DON'T set input_line_written=True here!
|
|
175
|
+
# Fullscreen uses alternate buffer - when it exits, the ORIGINAL screen is restored
|
|
176
|
+
# with the OLD input box in place. No clearing needed - just render at correct position.
|
|
177
|
+
# Setting input_line_written=True would cause clearing from wrong cursor position.
|
|
178
|
+
self.renderer.input_line_written = False
|
|
179
|
+
self.renderer.last_line_count = 0
|
|
180
|
+
self.renderer.invalidate_render_cache()
|
|
181
|
+
|
|
182
|
+
self.command_mode = CommandMode.NORMAL
|
|
183
|
+
# Clear fullscreen session flag when exiting modal
|
|
184
|
+
if hasattr(self, "_fullscreen_session_active"):
|
|
185
|
+
self._fullscreen_session_active = False
|
|
186
|
+
logger.info("Fullscreen session marked as inactive")
|
|
187
|
+
logger.info("Command mode reset to NORMAL after modal hide")
|
|
188
|
+
|
|
189
|
+
# Force refresh of display when exiting modal mode
|
|
190
|
+
await self._update_display(force_render=True)
|
|
191
|
+
return {"success": True, "modal_deactivated": True}
|
|
192
|
+
except Exception as e:
|
|
193
|
+
logger.error(f"Error handling modal hide: {e}")
|
|
194
|
+
return {"success": False, "error": str(e)}
|
|
195
|
+
|
|
196
|
+
async def _handle_modal_keypress(self, key_press) -> bool:
|
|
197
|
+
"""Handle KeyPress during modal mode.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
key_press: Parsed key press to process.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
True if key was handled.
|
|
204
|
+
"""
|
|
205
|
+
try:
|
|
206
|
+
# CRITICAL FIX: Check if this is a fullscreen plugin session first
|
|
207
|
+
if (
|
|
208
|
+
hasattr(self, "_fullscreen_session_active")
|
|
209
|
+
and self._fullscreen_session_active
|
|
210
|
+
):
|
|
211
|
+
# Route input to fullscreen session through event bus
|
|
212
|
+
# Let the plugin handle all input including exit keys
|
|
213
|
+
await self.event_bus.emit_with_hooks(
|
|
214
|
+
EventType.FULLSCREEN_INPUT,
|
|
215
|
+
{"key_press": key_press, "source": "input_handler"},
|
|
216
|
+
"input_handler",
|
|
217
|
+
)
|
|
218
|
+
return True
|
|
219
|
+
|
|
220
|
+
# Initialize modal renderer if needed
|
|
221
|
+
if not self.modal_renderer:
|
|
222
|
+
logger.warning(
|
|
223
|
+
"Modal keypress received but no modal renderer active"
|
|
224
|
+
)
|
|
225
|
+
await self._exit_modal_mode()
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
# Handle save confirmation if active
|
|
229
|
+
if self._pending_save_confirm:
|
|
230
|
+
handled = await self._handle_save_confirmation(key_press)
|
|
231
|
+
if handled:
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
# Handle navigation and widget interaction
|
|
235
|
+
logger.info(f"Modal processing key: {key_press.name}")
|
|
236
|
+
|
|
237
|
+
nav_handled = self.modal_renderer._handle_widget_navigation(key_press)
|
|
238
|
+
logger.info(f"Widget navigation handled: {nav_handled}")
|
|
239
|
+
if nav_handled:
|
|
240
|
+
# Re-render modal with updated focus
|
|
241
|
+
await self._refresh_modal_display()
|
|
242
|
+
return True
|
|
243
|
+
|
|
244
|
+
# Debug: Check modal_renderer state before handling input
|
|
245
|
+
logger.info(f"modal_renderer state: has_command_sections={getattr(self.modal_renderer, 'has_command_sections', 'N/A')}, "
|
|
246
|
+
f"command_items_len={len(getattr(self.modal_renderer, 'command_items', [])) if hasattr(self.modal_renderer, 'command_items') else 'N/A'}, "
|
|
247
|
+
f"widgets_len={len(getattr(self.modal_renderer, 'widgets', [])) if hasattr(self.modal_renderer, 'widgets') else 'N/A'}")
|
|
248
|
+
input_handled = self.modal_renderer._handle_widget_input(key_press)
|
|
249
|
+
logger.info(f"Widget input handled: {input_handled}")
|
|
250
|
+
if input_handled:
|
|
251
|
+
# Check if a command was selected (for command-style modals)
|
|
252
|
+
logger.info(f"Checking was_command_selected: {self.modal_renderer.was_command_selected() if hasattr(self.modal_renderer, 'was_command_selected') else 'N/A'}")
|
|
253
|
+
if self.modal_renderer.was_command_selected():
|
|
254
|
+
selected_cmd = self.modal_renderer.get_selected_command()
|
|
255
|
+
logger.info(f"Command selected: {selected_cmd}")
|
|
256
|
+
# Exit modal based on exit_mode or action type
|
|
257
|
+
# Commands that display their own messages need minimal exit (no input render)
|
|
258
|
+
exit_mode = selected_cmd.get("exit_mode", "normal") if selected_cmd else "normal"
|
|
259
|
+
action = selected_cmd.get("action", "") if selected_cmd else ""
|
|
260
|
+
# Actions that will display messages should use minimal exit to prevent duplicate input boxes
|
|
261
|
+
minimal_actions = ["resume_session", "branch_select_session", "branch_execute"]
|
|
262
|
+
if exit_mode == "minimal" or action in minimal_actions:
|
|
263
|
+
await self._exit_modal_mode_minimal()
|
|
264
|
+
else:
|
|
265
|
+
await self._exit_modal_mode()
|
|
266
|
+
# Emit event for plugins to handle modal command selection
|
|
267
|
+
if selected_cmd:
|
|
268
|
+
context = {"command": selected_cmd, "source": "modal"}
|
|
269
|
+
results = await self.event_bus.emit_with_hooks(
|
|
270
|
+
EventType.MODAL_COMMAND_SELECTED,
|
|
271
|
+
context,
|
|
272
|
+
"input_handler"
|
|
273
|
+
)
|
|
274
|
+
# Get modified data from hook results (main phase final_data)
|
|
275
|
+
final_data = results.get("main", {}).get("final_data", {}) if results else {}
|
|
276
|
+
# Display messages if plugin returned them
|
|
277
|
+
if final_data.get("display_messages"):
|
|
278
|
+
if hasattr(self, 'renderer') and self.renderer:
|
|
279
|
+
if hasattr(self.renderer, 'message_coordinator'):
|
|
280
|
+
self.renderer.message_coordinator.display_message_sequence(
|
|
281
|
+
final_data["display_messages"]
|
|
282
|
+
)
|
|
283
|
+
# DON'T call _update_display here - render loop will handle it.
|
|
284
|
+
# The display_message_sequence() finally block already:
|
|
285
|
+
# - Sets writing_messages=False (unblocks render loop)
|
|
286
|
+
# - Resets input_line_written=False, last_line_count=0
|
|
287
|
+
# - Invalidates render cache
|
|
288
|
+
# Calling _update_display here causes duplicate input boxes.
|
|
289
|
+
# Show modal if plugin returned one
|
|
290
|
+
if final_data.get("show_modal"):
|
|
291
|
+
from ...events.models import UIConfig
|
|
292
|
+
modal_config = final_data["show_modal"]
|
|
293
|
+
ui_config = UIConfig(type="modal", title=modal_config.get("title", ""), modal_config=modal_config)
|
|
294
|
+
await self._enter_modal_mode(ui_config)
|
|
295
|
+
return True
|
|
296
|
+
# Re-render modal with updated widget state
|
|
297
|
+
await self._refresh_modal_display()
|
|
298
|
+
return True
|
|
299
|
+
|
|
300
|
+
# Check for custom action keys defined in modal config
|
|
301
|
+
if self.modal_renderer and hasattr(self.modal_renderer, 'current_ui_config'):
|
|
302
|
+
ui_config = self.modal_renderer.current_ui_config
|
|
303
|
+
if ui_config and hasattr(ui_config, 'modal_config') and ui_config.modal_config:
|
|
304
|
+
actions = ui_config.modal_config.get('actions', [])
|
|
305
|
+
key_char = key_press.char or ""
|
|
306
|
+
key_name = key_press.name or ""
|
|
307
|
+
|
|
308
|
+
for action_def in actions:
|
|
309
|
+
action_key = action_def.get('key', '')
|
|
310
|
+
# Match by key name or char (case-insensitive for single chars)
|
|
311
|
+
if (action_key == key_name or
|
|
312
|
+
(len(action_key) == 1 and action_key.lower() == key_char.lower())):
|
|
313
|
+
|
|
314
|
+
action_name = action_def.get('action', '')
|
|
315
|
+
# Skip standard actions handled below
|
|
316
|
+
if action_name in ('select', 'cancel', 'submit'):
|
|
317
|
+
break
|
|
318
|
+
|
|
319
|
+
logger.info(f"Custom action key '{action_key}' matched: {action_name}")
|
|
320
|
+
|
|
321
|
+
# Get the currently selected command item if any
|
|
322
|
+
selected_cmd = None
|
|
323
|
+
if self.modal_renderer.has_command_sections:
|
|
324
|
+
selected_cmd = self.modal_renderer.get_selected_command()
|
|
325
|
+
|
|
326
|
+
# Exit modal and emit event with action and selected item
|
|
327
|
+
await self._exit_modal_mode()
|
|
328
|
+
|
|
329
|
+
context = {
|
|
330
|
+
"command": {
|
|
331
|
+
"action": action_name,
|
|
332
|
+
"profile_name": selected_cmd.get("profile_name") if selected_cmd else None,
|
|
333
|
+
"agent_name": selected_cmd.get("agent_name") if selected_cmd else None,
|
|
334
|
+
"skill_name": selected_cmd.get("skill_name") if selected_cmd else None,
|
|
335
|
+
},
|
|
336
|
+
"source": "modal_action_key"
|
|
337
|
+
}
|
|
338
|
+
results = await self.event_bus.emit_with_hooks(
|
|
339
|
+
EventType.MODAL_COMMAND_SELECTED,
|
|
340
|
+
context,
|
|
341
|
+
"input_handler"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Handle results
|
|
345
|
+
final_data = results.get("main", {}).get("final_data", {}) if results else {}
|
|
346
|
+
if final_data.get("display_messages"):
|
|
347
|
+
if hasattr(self.renderer, 'message_coordinator'):
|
|
348
|
+
self.renderer.message_coordinator.display_message_sequence(
|
|
349
|
+
final_data["display_messages"]
|
|
350
|
+
)
|
|
351
|
+
if final_data.get("show_modal"):
|
|
352
|
+
from ...events.models import UIConfig
|
|
353
|
+
modal_config = final_data["show_modal"]
|
|
354
|
+
new_ui_config = UIConfig(type="modal", title=modal_config.get("title", ""), modal_config=modal_config)
|
|
355
|
+
await self._enter_modal_mode(new_ui_config)
|
|
356
|
+
|
|
357
|
+
return True
|
|
358
|
+
|
|
359
|
+
if key_press.name in ("Escape", "Ctrl+C"):
|
|
360
|
+
logger.info("Processing Escape/Ctrl+C key for modal exit")
|
|
361
|
+
# Check for unsaved changes
|
|
362
|
+
if self.modal_renderer and self._has_pending_modal_changes():
|
|
363
|
+
self._pending_save_confirm = True
|
|
364
|
+
await self._show_save_confirmation()
|
|
365
|
+
return True
|
|
366
|
+
await self._exit_modal_mode()
|
|
367
|
+
return True
|
|
368
|
+
elif key_press.name == "Ctrl+S":
|
|
369
|
+
logger.info("Processing Ctrl+S for modal save")
|
|
370
|
+
await self._save_and_exit_modal()
|
|
371
|
+
return True
|
|
372
|
+
elif key_press.name == "Enter":
|
|
373
|
+
logger.info(
|
|
374
|
+
"ENTER KEY HIJACKED - This should not happen if widget handled it!"
|
|
375
|
+
)
|
|
376
|
+
# Try to save modal changes and exit
|
|
377
|
+
await self._save_and_exit_modal()
|
|
378
|
+
return True
|
|
379
|
+
|
|
380
|
+
return True
|
|
381
|
+
except Exception as e:
|
|
382
|
+
logger.error(f"Error handling modal keypress: {e}")
|
|
383
|
+
await self._exit_modal_mode()
|
|
384
|
+
return False
|
|
385
|
+
|
|
386
|
+
# ==================== LIVE MODAL HANDLERS ====================
|
|
387
|
+
|
|
388
|
+
async def _handle_live_modal_trigger(
|
|
389
|
+
self, event_data: Dict[str, Any], context: str = None
|
|
390
|
+
) -> Dict[str, Any]:
|
|
391
|
+
"""Handle live modal trigger events to show live modals.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
event_data: Event data containing content_generator, config, input_callback.
|
|
395
|
+
context: Hook execution context.
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
Dictionary with live modal result.
|
|
399
|
+
"""
|
|
400
|
+
try:
|
|
401
|
+
content_generator = event_data.get("content_generator")
|
|
402
|
+
config = event_data.get("config")
|
|
403
|
+
input_callback = event_data.get("input_callback")
|
|
404
|
+
|
|
405
|
+
if content_generator:
|
|
406
|
+
logger.info(f"Live modal trigger received: {config.title if config else 'untitled'}")
|
|
407
|
+
# Enter live modal mode (this will block until modal closes)
|
|
408
|
+
result = await self.enter_live_modal_mode(
|
|
409
|
+
content_generator,
|
|
410
|
+
config,
|
|
411
|
+
input_callback
|
|
412
|
+
)
|
|
413
|
+
return {"success": True, "live_modal_activated": True, "result": result}
|
|
414
|
+
else:
|
|
415
|
+
logger.warning("Live modal trigger received without content_generator")
|
|
416
|
+
return {"success": False, "error": "Missing content_generator"}
|
|
417
|
+
except Exception as e:
|
|
418
|
+
logger.error(f"Error handling live modal trigger: {e}")
|
|
419
|
+
return {"success": False, "error": str(e)}
|
|
420
|
+
|
|
421
|
+
async def _handle_live_modal_keypress(self, key_press) -> bool:
|
|
422
|
+
"""Handle keypress during live modal mode.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
key_press: Parsed key press to process.
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
True if key was handled.
|
|
429
|
+
"""
|
|
430
|
+
try:
|
|
431
|
+
logger.info(
|
|
432
|
+
f"LIVE_MODAL_KEY: name='{key_press.name}', char='{key_press.char}', code={key_press.code}"
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
# Forward to live modal renderer
|
|
436
|
+
if self.live_modal_renderer:
|
|
437
|
+
should_close = await self.live_modal_renderer.handle_input(key_press)
|
|
438
|
+
if should_close:
|
|
439
|
+
await self._exit_live_modal_mode()
|
|
440
|
+
return True
|
|
441
|
+
|
|
442
|
+
# Fallback: Escape always exits
|
|
443
|
+
if key_press.name == "Escape":
|
|
444
|
+
await self._exit_live_modal_mode()
|
|
445
|
+
return True
|
|
446
|
+
|
|
447
|
+
return True
|
|
448
|
+
|
|
449
|
+
except Exception as e:
|
|
450
|
+
logger.error(f"Error handling live modal keypress: {e}")
|
|
451
|
+
await self._exit_live_modal_mode()
|
|
452
|
+
return False
|
|
453
|
+
|
|
454
|
+
async def _handle_live_modal_input(self, char: str) -> bool:
|
|
455
|
+
"""Handle character input during live modal mode.
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
char: Character input to process.
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
True if input was handled.
|
|
462
|
+
"""
|
|
463
|
+
try:
|
|
464
|
+
# Convert char to KeyPress for consistent handling
|
|
465
|
+
from ..key_parser import KeyPress
|
|
466
|
+
key_press = KeyPress(char=char, name=None, code=ord(char) if char else 0)
|
|
467
|
+
return await self._handle_live_modal_keypress(key_press)
|
|
468
|
+
|
|
469
|
+
except Exception as e:
|
|
470
|
+
logger.error(f"Error handling live modal input: {e}")
|
|
471
|
+
await self._exit_live_modal_mode()
|
|
472
|
+
return False
|
|
473
|
+
|
|
474
|
+
async def enter_live_modal_mode(
|
|
475
|
+
self,
|
|
476
|
+
content_generator,
|
|
477
|
+
config=None,
|
|
478
|
+
input_callback=None
|
|
479
|
+
) -> Dict[str, Any]:
|
|
480
|
+
"""Enter live modal mode with continuously updating content.
|
|
481
|
+
|
|
482
|
+
This is non-blocking - it starts the modal and returns immediately.
|
|
483
|
+
The input loop continues to process keys, routing them to the modal.
|
|
484
|
+
Press Escape to exit the modal.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
content_generator: Function returning List[str] of current content.
|
|
488
|
+
config: LiveModalConfig instance.
|
|
489
|
+
input_callback: Optional callback for input passthrough.
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
Result dict indicating modal was started.
|
|
493
|
+
"""
|
|
494
|
+
try:
|
|
495
|
+
from ...ui.live_modal_renderer import LiveModalRenderer, LiveModalConfig
|
|
496
|
+
|
|
497
|
+
# Store state
|
|
498
|
+
self.command_mode = CommandMode.LIVE_MODAL
|
|
499
|
+
self.live_modal_content_generator = content_generator
|
|
500
|
+
self.live_modal_input_callback = input_callback
|
|
501
|
+
|
|
502
|
+
# Create and store the live modal renderer
|
|
503
|
+
terminal_state = self.renderer.terminal_state
|
|
504
|
+
self.live_modal_renderer = LiveModalRenderer(terminal_state)
|
|
505
|
+
|
|
506
|
+
# Use default config if none provided
|
|
507
|
+
if config is None:
|
|
508
|
+
config = LiveModalConfig()
|
|
509
|
+
|
|
510
|
+
logger.info(f"Entering live modal mode: {config.title}")
|
|
511
|
+
|
|
512
|
+
# Start the live modal (non-blocking)
|
|
513
|
+
# The refresh loop runs as a background task
|
|
514
|
+
# Input will be handled by _handle_live_modal_keypress
|
|
515
|
+
success = self.live_modal_renderer.start_live_modal(
|
|
516
|
+
content_generator,
|
|
517
|
+
config,
|
|
518
|
+
input_callback
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
if success:
|
|
522
|
+
return {"success": True, "modal_started": True}
|
|
523
|
+
else:
|
|
524
|
+
await self._exit_live_modal_mode()
|
|
525
|
+
return {"success": False, "error": "Failed to start modal"}
|
|
526
|
+
|
|
527
|
+
except Exception as e:
|
|
528
|
+
logger.error(f"Error entering live modal mode: {e}")
|
|
529
|
+
await self._exit_live_modal_mode()
|
|
530
|
+
return {"success": False, "error": str(e)}
|
|
531
|
+
|
|
532
|
+
async def _exit_live_modal_mode(self):
|
|
533
|
+
"""Exit live modal mode and restore terminal."""
|
|
534
|
+
try:
|
|
535
|
+
logger.info("Exiting live modal mode...")
|
|
536
|
+
|
|
537
|
+
# Close the live modal renderer (restores from alt buffer)
|
|
538
|
+
if self.live_modal_renderer:
|
|
539
|
+
await self.live_modal_renderer.close_modal()
|
|
540
|
+
|
|
541
|
+
# Reset state
|
|
542
|
+
self.command_mode = CommandMode.NORMAL
|
|
543
|
+
self.live_modal_renderer = None
|
|
544
|
+
self.live_modal_content_generator = None
|
|
545
|
+
self.live_modal_input_callback = None
|
|
546
|
+
|
|
547
|
+
# Force display refresh with full redraw
|
|
548
|
+
self.renderer.clear_active_area()
|
|
549
|
+
await self._update_display(force_render=True)
|
|
550
|
+
|
|
551
|
+
logger.info("Live modal mode exited successfully")
|
|
552
|
+
|
|
553
|
+
except Exception as e:
|
|
554
|
+
logger.error(f"Error exiting live modal mode: {e}")
|
|
555
|
+
self.command_mode = CommandMode.NORMAL
|
|
556
|
+
|
|
557
|
+
# ==================== STATUS MODAL HANDLERS ====================
|
|
558
|
+
|
|
559
|
+
async def _handle_status_modal_trigger(
|
|
560
|
+
self, event_data: Dict[str, Any], context: str = None
|
|
561
|
+
) -> Dict[str, Any]:
|
|
562
|
+
"""Handle status modal trigger events to show status modals.
|
|
563
|
+
|
|
564
|
+
Args:
|
|
565
|
+
event_data: Event data containing modal configuration.
|
|
566
|
+
context: Hook execution context.
|
|
567
|
+
|
|
568
|
+
Returns:
|
|
569
|
+
Dictionary with status modal result.
|
|
570
|
+
"""
|
|
571
|
+
try:
|
|
572
|
+
ui_config = event_data.get("ui_config")
|
|
573
|
+
if ui_config:
|
|
574
|
+
logger.info(f"Status modal trigger received: {ui_config.title}")
|
|
575
|
+
logger.info(f"Status modal trigger UI config type: {ui_config.type}")
|
|
576
|
+
await self._enter_status_modal_mode(ui_config)
|
|
577
|
+
return {"success": True, "status_modal_activated": True}
|
|
578
|
+
else:
|
|
579
|
+
logger.warning("Status modal trigger received without ui_config")
|
|
580
|
+
return {"success": False, "error": "Missing ui_config"}
|
|
581
|
+
except Exception as e:
|
|
582
|
+
logger.error(f"Error handling status modal trigger: {e}")
|
|
583
|
+
return {"success": False, "error": str(e)}
|
|
584
|
+
|
|
585
|
+
async def _enter_status_modal_mode(self, ui_config):
|
|
586
|
+
"""Enter status modal mode - modal confined to status area.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
ui_config: Status modal configuration.
|
|
590
|
+
"""
|
|
591
|
+
try:
|
|
592
|
+
# Set status modal mode
|
|
593
|
+
self.command_mode = CommandMode.STATUS_MODAL
|
|
594
|
+
self.current_status_modal_config = ui_config
|
|
595
|
+
logger.info(f"Entered status modal mode: {ui_config.title}")
|
|
596
|
+
|
|
597
|
+
# Unlike full modals, status modals don't take over the screen
|
|
598
|
+
# They just appear in the status area via the renderer
|
|
599
|
+
await self._update_display(force_render=True)
|
|
600
|
+
|
|
601
|
+
except Exception as e:
|
|
602
|
+
logger.error(f"Error entering status modal mode: {e}")
|
|
603
|
+
await self._exit_command_mode()
|
|
604
|
+
|
|
605
|
+
async def _handle_status_modal_keypress(self, key_press) -> bool:
|
|
606
|
+
"""Handle keypress during status modal mode.
|
|
607
|
+
|
|
608
|
+
Args:
|
|
609
|
+
key_press: Parsed key press to process.
|
|
610
|
+
|
|
611
|
+
Returns:
|
|
612
|
+
True if key was handled, False otherwise.
|
|
613
|
+
"""
|
|
614
|
+
try:
|
|
615
|
+
logger.info(
|
|
616
|
+
f"Status modal received key: name='{key_press.name}', char='{key_press.char}', code={key_press.code}"
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
if key_press.name == "Escape":
|
|
620
|
+
logger.info("Escape key detected, closing status modal")
|
|
621
|
+
await self._exit_status_modal_mode()
|
|
622
|
+
return True
|
|
623
|
+
elif key_press.name == "Enter":
|
|
624
|
+
logger.info("Enter key detected, closing status modal")
|
|
625
|
+
await self._exit_status_modal_mode()
|
|
626
|
+
return True
|
|
627
|
+
elif key_press.char and ord(key_press.char) == 3: # Ctrl+C
|
|
628
|
+
logger.info("Ctrl+C detected, closing status modal")
|
|
629
|
+
await self._exit_status_modal_mode()
|
|
630
|
+
return True
|
|
631
|
+
else:
|
|
632
|
+
logger.info(f"Unhandled key in status modal: {key_press.name}")
|
|
633
|
+
return True
|
|
634
|
+
|
|
635
|
+
except Exception as e:
|
|
636
|
+
logger.error(f"Error handling status modal keypress: {e}")
|
|
637
|
+
await self._exit_status_modal_mode()
|
|
638
|
+
return False
|
|
639
|
+
|
|
640
|
+
async def _handle_status_modal_input(self, char: str) -> bool:
|
|
641
|
+
"""Handle input during status modal mode.
|
|
642
|
+
|
|
643
|
+
Args:
|
|
644
|
+
char: Character input to process.
|
|
645
|
+
|
|
646
|
+
Returns:
|
|
647
|
+
True if input was handled, False otherwise.
|
|
648
|
+
"""
|
|
649
|
+
try:
|
|
650
|
+
# For now, ignore character input in status modals
|
|
651
|
+
# Could add search/filter functionality later
|
|
652
|
+
return True
|
|
653
|
+
except Exception as e:
|
|
654
|
+
logger.error(f"Error handling status modal input: {e}")
|
|
655
|
+
await self._exit_status_modal_mode()
|
|
656
|
+
return False
|
|
657
|
+
|
|
658
|
+
async def _exit_status_modal_mode(self):
|
|
659
|
+
"""Exit status modal mode and return to normal input."""
|
|
660
|
+
try:
|
|
661
|
+
logger.info("Exiting status modal mode...")
|
|
662
|
+
self.command_mode = CommandMode.NORMAL
|
|
663
|
+
self.current_status_modal_config = None
|
|
664
|
+
logger.info("Status modal mode exited successfully")
|
|
665
|
+
|
|
666
|
+
# Refresh display to remove the status modal
|
|
667
|
+
await self._update_display(force_render=True)
|
|
668
|
+
logger.info("Display updated after status modal exit")
|
|
669
|
+
|
|
670
|
+
except Exception as e:
|
|
671
|
+
logger.error(f"Error exiting status modal mode: {e}")
|
|
672
|
+
self.command_mode = CommandMode.NORMAL
|
|
673
|
+
|
|
674
|
+
async def _handle_status_modal_render(
|
|
675
|
+
self, event_data: Dict[str, Any], context: str = None
|
|
676
|
+
) -> Dict[str, Any]:
|
|
677
|
+
"""Handle status modal render events to provide modal display lines.
|
|
678
|
+
|
|
679
|
+
Args:
|
|
680
|
+
event_data: Event data containing render request.
|
|
681
|
+
context: Hook execution context.
|
|
682
|
+
|
|
683
|
+
Returns:
|
|
684
|
+
Dictionary with status modal lines if active.
|
|
685
|
+
"""
|
|
686
|
+
try:
|
|
687
|
+
if (
|
|
688
|
+
self.command_mode == CommandMode.STATUS_MODAL
|
|
689
|
+
and self.current_status_modal_config
|
|
690
|
+
):
|
|
691
|
+
|
|
692
|
+
# Generate status modal display lines
|
|
693
|
+
modal_lines = self._generate_status_modal_lines(
|
|
694
|
+
self.current_status_modal_config
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
return {"success": True, "status_modal_lines": modal_lines}
|
|
698
|
+
else:
|
|
699
|
+
return {"success": True, "status_modal_lines": []}
|
|
700
|
+
|
|
701
|
+
except Exception as e:
|
|
702
|
+
logger.error(f"Error handling status modal render: {e}")
|
|
703
|
+
return {"success": False, "status_modal_lines": []}
|
|
704
|
+
|
|
705
|
+
def _generate_status_modal_lines(self, ui_config) -> List[str]:
|
|
706
|
+
"""Generate formatted lines for status modal display using visual effects.
|
|
707
|
+
|
|
708
|
+
Delegates to StatusModalRenderer component (Phase 1 extraction).
|
|
709
|
+
|
|
710
|
+
Args:
|
|
711
|
+
ui_config: UI configuration for the status modal.
|
|
712
|
+
|
|
713
|
+
Returns:
|
|
714
|
+
List of formatted lines for display.
|
|
715
|
+
"""
|
|
716
|
+
return self._status_modal_renderer.generate_status_modal_lines(ui_config)
|
|
717
|
+
|
|
718
|
+
# ==================== STANDARD MODAL OPERATIONS ====================
|
|
719
|
+
|
|
720
|
+
async def _show_modal_from_result(self, result):
|
|
721
|
+
"""Show a modal from a command result.
|
|
722
|
+
|
|
723
|
+
Args:
|
|
724
|
+
result: CommandResult with ui_config for modal display.
|
|
725
|
+
"""
|
|
726
|
+
if result and result.ui_config:
|
|
727
|
+
await self._enter_modal_mode(result.ui_config)
|
|
728
|
+
|
|
729
|
+
async def _enter_modal_mode(self, ui_config):
|
|
730
|
+
"""Enter modal mode and show modal renderer.
|
|
731
|
+
|
|
732
|
+
Args:
|
|
733
|
+
ui_config: Modal configuration.
|
|
734
|
+
"""
|
|
735
|
+
try:
|
|
736
|
+
# Import modal renderer here to avoid circular imports
|
|
737
|
+
from ...ui.modal_renderer import ModalRenderer
|
|
738
|
+
|
|
739
|
+
# Create modal renderer instance with proper config service
|
|
740
|
+
self.modal_renderer = ModalRenderer(
|
|
741
|
+
terminal_renderer=self.renderer,
|
|
742
|
+
visual_effects=getattr(self.renderer, "visual_effects", None),
|
|
743
|
+
config_service=self.config, # Use config as config service
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
# Pause render loop during modal
|
|
747
|
+
self.renderer.writing_messages = True
|
|
748
|
+
self.renderer.clear_active_area()
|
|
749
|
+
|
|
750
|
+
# Set modal mode
|
|
751
|
+
self.command_mode = CommandMode.MODAL
|
|
752
|
+
logger.info(f"Command mode set to: {self.command_mode}")
|
|
753
|
+
|
|
754
|
+
# Show the modal (handles its own alternate buffer)
|
|
755
|
+
await self.modal_renderer.show_modal(ui_config)
|
|
756
|
+
|
|
757
|
+
logger.info("Entered modal mode")
|
|
758
|
+
|
|
759
|
+
except Exception as e:
|
|
760
|
+
logger.error(f"Error entering modal mode: {e}")
|
|
761
|
+
self.command_mode = CommandMode.NORMAL
|
|
762
|
+
self.renderer.writing_messages = False
|
|
763
|
+
|
|
764
|
+
async def _refresh_modal_display(self):
|
|
765
|
+
"""Refresh modal display after widget interactions."""
|
|
766
|
+
try:
|
|
767
|
+
if self.modal_renderer and hasattr(
|
|
768
|
+
self.modal_renderer, "current_ui_config"
|
|
769
|
+
):
|
|
770
|
+
|
|
771
|
+
# CRITICAL FIX: Force complete display clearing to prevent duplication
|
|
772
|
+
# Clear active area completely before refresh
|
|
773
|
+
self.renderer.clear_active_area()
|
|
774
|
+
|
|
775
|
+
# Clear any message buffers that might accumulate content
|
|
776
|
+
if hasattr(self.renderer, "message_renderer"):
|
|
777
|
+
if hasattr(self.renderer.message_renderer, "buffer"):
|
|
778
|
+
self.renderer.message_renderer.buffer.clear_buffer()
|
|
779
|
+
# Also clear any accumulated messages in the renderer
|
|
780
|
+
if hasattr(self.renderer.message_renderer, "clear_messages"):
|
|
781
|
+
self.renderer.message_renderer.clear_messages()
|
|
782
|
+
|
|
783
|
+
# Re-render the modal with current widget states (preserve widgets!)
|
|
784
|
+
modal_lines = self.modal_renderer._render_modal_box(
|
|
785
|
+
self.modal_renderer.current_ui_config,
|
|
786
|
+
preserve_widgets=True,
|
|
787
|
+
)
|
|
788
|
+
# FIXED: Use state_manager.render_modal_content() instead of _render_modal_lines()
|
|
789
|
+
# to avoid re-calling prepare_modal_display() which causes buffer switching
|
|
790
|
+
if self.modal_renderer.state_manager:
|
|
791
|
+
self.modal_renderer.state_manager.render_modal_content(
|
|
792
|
+
modal_lines
|
|
793
|
+
)
|
|
794
|
+
else:
|
|
795
|
+
# Fallback to old method if state_manager not available
|
|
796
|
+
await self.modal_renderer._render_modal_lines(modal_lines)
|
|
797
|
+
else:
|
|
798
|
+
pass
|
|
799
|
+
except Exception as e:
|
|
800
|
+
logger.error(f"Error refreshing modal display: {e}")
|
|
801
|
+
|
|
802
|
+
def _has_pending_modal_changes(self) -> bool:
|
|
803
|
+
"""Check if there are unsaved changes in modal widgets."""
|
|
804
|
+
if not self.modal_renderer or not self.modal_renderer.widgets:
|
|
805
|
+
return False
|
|
806
|
+
for widget in self.modal_renderer.widgets:
|
|
807
|
+
if hasattr(widget, '_pending_value') and widget._pending_value is not None:
|
|
808
|
+
# Check if pending value differs from current config value
|
|
809
|
+
current = widget.get_value() if hasattr(widget, 'get_value') else None
|
|
810
|
+
if widget._pending_value != current:
|
|
811
|
+
return True
|
|
812
|
+
return False
|
|
813
|
+
|
|
814
|
+
async def _show_save_confirmation(self):
|
|
815
|
+
"""Show save confirmation prompt in modal."""
|
|
816
|
+
# Update modal footer to show confirmation prompt
|
|
817
|
+
if self.modal_renderer:
|
|
818
|
+
self.modal_renderer._save_confirm_active = True
|
|
819
|
+
await self._refresh_modal_display()
|
|
820
|
+
|
|
821
|
+
async def _handle_save_confirmation(self, key_press) -> bool:
|
|
822
|
+
"""Handle y/n input for save confirmation."""
|
|
823
|
+
if key_press.char and key_press.char.lower() == 'y':
|
|
824
|
+
logger.info("User confirmed save")
|
|
825
|
+
self._pending_save_confirm = False
|
|
826
|
+
if self.modal_renderer:
|
|
827
|
+
self.modal_renderer._save_confirm_active = False
|
|
828
|
+
await self._save_and_exit_modal()
|
|
829
|
+
return True
|
|
830
|
+
elif key_press.char and key_press.char.lower() == 'n':
|
|
831
|
+
logger.info("User declined save")
|
|
832
|
+
self._pending_save_confirm = False
|
|
833
|
+
if self.modal_renderer:
|
|
834
|
+
self.modal_renderer._save_confirm_active = False
|
|
835
|
+
await self._exit_modal_mode()
|
|
836
|
+
return True
|
|
837
|
+
elif key_press.name == "Escape":
|
|
838
|
+
# Cancel confirmation, stay in modal
|
|
839
|
+
logger.info("User cancelled confirmation")
|
|
840
|
+
self._pending_save_confirm = False
|
|
841
|
+
if self.modal_renderer:
|
|
842
|
+
self.modal_renderer._save_confirm_active = False
|
|
843
|
+
await self._refresh_modal_display()
|
|
844
|
+
return True
|
|
845
|
+
return False
|
|
846
|
+
|
|
847
|
+
async def _save_and_exit_modal(self):
|
|
848
|
+
"""Save modal changes and exit modal mode."""
|
|
849
|
+
try:
|
|
850
|
+
if self.modal_renderer:
|
|
851
|
+
# Check if this is a form modal with form_action
|
|
852
|
+
modal_config = getattr(self.modal_renderer, 'current_ui_config', None)
|
|
853
|
+
form_action = None
|
|
854
|
+
if modal_config and hasattr(modal_config, 'modal_config'):
|
|
855
|
+
form_action = modal_config.modal_config.get('form_action')
|
|
856
|
+
|
|
857
|
+
if form_action and self.modal_renderer.widgets:
|
|
858
|
+
# Collect form data from widgets
|
|
859
|
+
form_data = {}
|
|
860
|
+
for widget in self.modal_renderer.widgets:
|
|
861
|
+
widget_type = widget.__class__.__name__
|
|
862
|
+
config_path = getattr(widget, 'config_path', None)
|
|
863
|
+
pending = getattr(widget, '_pending_value', 'NO_ATTR')
|
|
864
|
+
logger.info(f"Widget: {widget_type}, config_path={config_path}, _pending_value={pending}")
|
|
865
|
+
|
|
866
|
+
if hasattr(widget, 'config_path') and widget.config_path:
|
|
867
|
+
# Use field name (last part of config path)
|
|
868
|
+
field_name = widget.config_path.split('.')[-1]
|
|
869
|
+
# Always use get_pending_value() which returns:
|
|
870
|
+
# - _pending_value if user modified the field
|
|
871
|
+
# - Original value from config if not modified
|
|
872
|
+
# This ensures edit forms preserve unmodified values
|
|
873
|
+
if hasattr(widget, 'get_pending_value'):
|
|
874
|
+
form_data[field_name] = widget.get_pending_value()
|
|
875
|
+
elif hasattr(widget, '_pending_value') and widget._pending_value is not None:
|
|
876
|
+
form_data[field_name] = widget._pending_value
|
|
877
|
+
else:
|
|
878
|
+
form_data[field_name] = ""
|
|
879
|
+
|
|
880
|
+
logger.info(f"Form submission: action={form_action}, data={form_data}")
|
|
881
|
+
|
|
882
|
+
# Get any extra fields from modal_config (like edit_profile_name)
|
|
883
|
+
extra_fields = {}
|
|
884
|
+
if modal_config and hasattr(modal_config, 'modal_config'):
|
|
885
|
+
mc = modal_config.modal_config
|
|
886
|
+
# Pass through known extra fields for edit operations
|
|
887
|
+
for field in ['edit_profile_name', 'edit_agent_name', 'edit_skill_name']:
|
|
888
|
+
if field in mc:
|
|
889
|
+
extra_fields[field] = mc[field]
|
|
890
|
+
|
|
891
|
+
# Exit modal first
|
|
892
|
+
await self._exit_modal_mode()
|
|
893
|
+
|
|
894
|
+
# Emit MODAL_COMMAND_SELECTED with form action and data
|
|
895
|
+
context = {
|
|
896
|
+
"command": {
|
|
897
|
+
"action": form_action,
|
|
898
|
+
"form_data": form_data,
|
|
899
|
+
**extra_fields, # Include edit_profile_name etc.
|
|
900
|
+
},
|
|
901
|
+
"source": "modal_form"
|
|
902
|
+
}
|
|
903
|
+
results = await self.event_bus.emit_with_hooks(
|
|
904
|
+
EventType.MODAL_COMMAND_SELECTED,
|
|
905
|
+
context,
|
|
906
|
+
"input_handler"
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
# Get modified data from hook results
|
|
910
|
+
final_data = results.get("main", {}).get("final_data", {}) if results else {}
|
|
911
|
+
|
|
912
|
+
# Display messages if returned
|
|
913
|
+
if final_data.get("display_messages"):
|
|
914
|
+
if hasattr(self.renderer, 'message_coordinator'):
|
|
915
|
+
self.renderer.message_coordinator.display_message_sequence(
|
|
916
|
+
final_data["display_messages"]
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
# Show modal if plugin returned one
|
|
920
|
+
if final_data.get("show_modal"):
|
|
921
|
+
from ...events.models import UIConfig
|
|
922
|
+
modal_config = final_data["show_modal"]
|
|
923
|
+
ui_config = UIConfig(type="modal", title=modal_config.get("title", ""), modal_config=modal_config)
|
|
924
|
+
await self._enter_modal_mode(ui_config)
|
|
925
|
+
|
|
926
|
+
return
|
|
927
|
+
|
|
928
|
+
# Fallback: use action handler for config-based modals
|
|
929
|
+
if hasattr(self.modal_renderer, "action_handler"):
|
|
930
|
+
result = await self.modal_renderer.action_handler.handle_action(
|
|
931
|
+
"save", self.modal_renderer.widgets
|
|
932
|
+
)
|
|
933
|
+
if not result.get("success"):
|
|
934
|
+
logger.warning(
|
|
935
|
+
f"Failed to save modal changes: {result.get('message', 'Unknown error')}"
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
await self._exit_modal_mode()
|
|
939
|
+
except Exception as e:
|
|
940
|
+
logger.error(f"Error saving and exiting modal: {e}")
|
|
941
|
+
await self._exit_modal_mode()
|
|
942
|
+
|
|
943
|
+
async def _exit_modal_mode(self):
|
|
944
|
+
"""Exit modal mode using existing patterns."""
|
|
945
|
+
try:
|
|
946
|
+
# Close modal renderer (handles its own terminal restoration)
|
|
947
|
+
if self.modal_renderer:
|
|
948
|
+
_ = self.modal_renderer.close_modal()
|
|
949
|
+
self.modal_renderer.widgets = []
|
|
950
|
+
self.modal_renderer.focused_widget_index = 0
|
|
951
|
+
self.modal_renderer = None
|
|
952
|
+
|
|
953
|
+
# Return to normal mode
|
|
954
|
+
self.command_mode = CommandMode.NORMAL
|
|
955
|
+
|
|
956
|
+
# Resume render loop
|
|
957
|
+
self.renderer.writing_messages = False
|
|
958
|
+
self.renderer.invalidate_render_cache()
|
|
959
|
+
await self._update_display(force_render=True)
|
|
960
|
+
|
|
961
|
+
except Exception as e:
|
|
962
|
+
logger.error(f"Error exiting modal mode: {e}")
|
|
963
|
+
self.command_mode = CommandMode.NORMAL
|
|
964
|
+
self.modal_renderer = None
|
|
965
|
+
self.renderer.writing_messages = False
|
|
966
|
+
|
|
967
|
+
async def _exit_modal_mode_minimal(self):
|
|
968
|
+
"""Exit modal mode WITHOUT rendering input - for commands that display their own content.
|
|
969
|
+
|
|
970
|
+
Use this when a command (like /branch, /resume) will immediately display its own
|
|
971
|
+
content after modal closes. This prevents duplicate input boxes.
|
|
972
|
+
|
|
973
|
+
CRITICAL STATE MANAGEMENT:
|
|
974
|
+
- input_line_written=True: Marks content exists on screen
|
|
975
|
+
- last_line_count=0: Prevents clear_active_area() from clearing stale lines
|
|
976
|
+
(after modal exit, the stale last_line_count could clear into banner)
|
|
977
|
+
"""
|
|
978
|
+
try:
|
|
979
|
+
# Close modal renderer (handles its own terminal restoration via alternate buffer)
|
|
980
|
+
if self.modal_renderer:
|
|
981
|
+
_ = self.modal_renderer.close_modal()
|
|
982
|
+
self.modal_renderer.widgets = []
|
|
983
|
+
self.modal_renderer.focused_widget_index = 0
|
|
984
|
+
self.modal_renderer = None
|
|
985
|
+
|
|
986
|
+
# Return to normal mode
|
|
987
|
+
self.command_mode = CommandMode.NORMAL
|
|
988
|
+
|
|
989
|
+
# KEEP writing_messages=True to block render loop!
|
|
990
|
+
# The calling command's display_message_sequence() will set it False when done.
|
|
991
|
+
# This prevents the race condition where render loop runs before command displays.
|
|
992
|
+
# self.renderer.writing_messages = False # DON'T DO THIS - causes race condition
|
|
993
|
+
|
|
994
|
+
# After modal closes (alternate buffer exit), the OLD input box from before
|
|
995
|
+
# the modal is restored on screen. We need clear_active_area() in
|
|
996
|
+
# display_message_sequence() to clear it.
|
|
997
|
+
#
|
|
998
|
+
# CRITICAL: Set input_line_written=True so clear_active_area() will actually clear!
|
|
999
|
+
# When the modal opened, clear_active_area() set input_line_written=False.
|
|
1000
|
+
# Now that we're back to main buffer with old input box, we need this True.
|
|
1001
|
+
self.renderer.input_line_written = True
|
|
1002
|
+
# last_line_count should still have the correct value from before modal opened
|
|
1003
|
+
self.renderer.invalidate_render_cache()
|
|
1004
|
+
# NOTE: No _update_display() call here - command will handle display
|
|
1005
|
+
|
|
1006
|
+
except Exception as e:
|
|
1007
|
+
logger.error(f"Error exiting modal mode (minimal): {e}")
|
|
1008
|
+
self.command_mode = CommandMode.NORMAL
|
|
1009
|
+
self.modal_renderer = None
|
|
1010
|
+
# Keep render state as-is for clearing
|
|
1011
|
+
self.renderer.invalidate_render_cache()
|