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.
Files changed (192) hide show
  1. agents/__init__.py +2 -0
  2. agents/coder/__init__.py +0 -0
  3. agents/coder/agent.json +4 -0
  4. agents/coder/api-integration.md +2150 -0
  5. agents/coder/cli-pretty.md +765 -0
  6. agents/coder/code-review.md +1092 -0
  7. agents/coder/database-design.md +1525 -0
  8. agents/coder/debugging.md +1102 -0
  9. agents/coder/dependency-management.md +1397 -0
  10. agents/coder/git-workflow.md +1099 -0
  11. agents/coder/refactoring.md +1454 -0
  12. agents/coder/security-hardening.md +1732 -0
  13. agents/coder/system_prompt.md +1448 -0
  14. agents/coder/tdd.md +1367 -0
  15. agents/creative-writer/__init__.py +0 -0
  16. agents/creative-writer/agent.json +4 -0
  17. agents/creative-writer/character-development.md +1852 -0
  18. agents/creative-writer/dialogue-craft.md +1122 -0
  19. agents/creative-writer/plot-structure.md +1073 -0
  20. agents/creative-writer/revision-editing.md +1484 -0
  21. agents/creative-writer/system_prompt.md +690 -0
  22. agents/creative-writer/worldbuilding.md +2049 -0
  23. agents/data-analyst/__init__.py +30 -0
  24. agents/data-analyst/agent.json +4 -0
  25. agents/data-analyst/data-visualization.md +992 -0
  26. agents/data-analyst/exploratory-data-analysis.md +1110 -0
  27. agents/data-analyst/pandas-data-manipulation.md +1081 -0
  28. agents/data-analyst/sql-query-optimization.md +881 -0
  29. agents/data-analyst/statistical-analysis.md +1118 -0
  30. agents/data-analyst/system_prompt.md +928 -0
  31. agents/default/__init__.py +0 -0
  32. agents/default/agent.json +4 -0
  33. agents/default/dead-code.md +794 -0
  34. agents/default/explore-agent-system.md +585 -0
  35. agents/default/system_prompt.md +1448 -0
  36. agents/kollabor/__init__.py +0 -0
  37. agents/kollabor/analyze-plugin-lifecycle.md +175 -0
  38. agents/kollabor/analyze-terminal-rendering.md +388 -0
  39. agents/kollabor/code-review.md +1092 -0
  40. agents/kollabor/debug-mcp-integration.md +521 -0
  41. agents/kollabor/debug-plugin-hooks.md +547 -0
  42. agents/kollabor/debugging.md +1102 -0
  43. agents/kollabor/dependency-management.md +1397 -0
  44. agents/kollabor/git-workflow.md +1099 -0
  45. agents/kollabor/inspect-llm-conversation.md +148 -0
  46. agents/kollabor/monitor-event-bus.md +558 -0
  47. agents/kollabor/profile-performance.md +576 -0
  48. agents/kollabor/refactoring.md +1454 -0
  49. agents/kollabor/system_prompt copy.md +1448 -0
  50. agents/kollabor/system_prompt.md +757 -0
  51. agents/kollabor/trace-command-execution.md +178 -0
  52. agents/kollabor/validate-config.md +879 -0
  53. agents/research/__init__.py +0 -0
  54. agents/research/agent.json +4 -0
  55. agents/research/architecture-mapping.md +1099 -0
  56. agents/research/codebase-analysis.md +1077 -0
  57. agents/research/dependency-audit.md +1027 -0
  58. agents/research/performance-profiling.md +1047 -0
  59. agents/research/security-review.md +1359 -0
  60. agents/research/system_prompt.md +492 -0
  61. agents/technical-writer/__init__.py +0 -0
  62. agents/technical-writer/agent.json +4 -0
  63. agents/technical-writer/api-documentation.md +2328 -0
  64. agents/technical-writer/changelog-management.md +1181 -0
  65. agents/technical-writer/readme-writing.md +1360 -0
  66. agents/technical-writer/style-guide.md +1410 -0
  67. agents/technical-writer/system_prompt.md +653 -0
  68. agents/technical-writer/tutorial-creation.md +1448 -0
  69. core/__init__.py +0 -2
  70. core/application.py +343 -88
  71. core/cli.py +229 -10
  72. core/commands/menu_renderer.py +463 -59
  73. core/commands/registry.py +14 -9
  74. core/commands/system_commands.py +2461 -14
  75. core/config/loader.py +151 -37
  76. core/config/service.py +18 -6
  77. core/events/bus.py +29 -9
  78. core/events/executor.py +205 -75
  79. core/events/models.py +27 -8
  80. core/fullscreen/command_integration.py +20 -24
  81. core/fullscreen/components/__init__.py +10 -1
  82. core/fullscreen/components/matrix_components.py +1 -2
  83. core/fullscreen/components/space_shooter_components.py +654 -0
  84. core/fullscreen/plugin.py +5 -0
  85. core/fullscreen/renderer.py +52 -13
  86. core/fullscreen/session.py +52 -15
  87. core/io/__init__.py +29 -5
  88. core/io/buffer_manager.py +6 -1
  89. core/io/config_status_view.py +7 -29
  90. core/io/core_status_views.py +267 -347
  91. core/io/input/__init__.py +25 -0
  92. core/io/input/command_mode_handler.py +711 -0
  93. core/io/input/display_controller.py +128 -0
  94. core/io/input/hook_registrar.py +286 -0
  95. core/io/input/input_loop_manager.py +421 -0
  96. core/io/input/key_press_handler.py +502 -0
  97. core/io/input/modal_controller.py +1011 -0
  98. core/io/input/paste_processor.py +339 -0
  99. core/io/input/status_modal_renderer.py +184 -0
  100. core/io/input_errors.py +5 -1
  101. core/io/input_handler.py +211 -2452
  102. core/io/key_parser.py +7 -0
  103. core/io/layout.py +15 -3
  104. core/io/message_coordinator.py +111 -2
  105. core/io/message_renderer.py +129 -4
  106. core/io/status_renderer.py +147 -607
  107. core/io/terminal_renderer.py +97 -51
  108. core/io/terminal_state.py +21 -4
  109. core/io/visual_effects.py +816 -165
  110. core/llm/agent_manager.py +1063 -0
  111. core/llm/api_adapters/__init__.py +44 -0
  112. core/llm/api_adapters/anthropic_adapter.py +432 -0
  113. core/llm/api_adapters/base.py +241 -0
  114. core/llm/api_adapters/openai_adapter.py +326 -0
  115. core/llm/api_communication_service.py +167 -113
  116. core/llm/conversation_logger.py +322 -16
  117. core/llm/conversation_manager.py +556 -30
  118. core/llm/file_operations_executor.py +84 -32
  119. core/llm/llm_service.py +934 -103
  120. core/llm/mcp_integration.py +541 -57
  121. core/llm/message_display_service.py +135 -18
  122. core/llm/plugin_sdk.py +1 -2
  123. core/llm/profile_manager.py +1183 -0
  124. core/llm/response_parser.py +274 -56
  125. core/llm/response_processor.py +16 -3
  126. core/llm/tool_executor.py +6 -1
  127. core/logging/__init__.py +2 -0
  128. core/logging/setup.py +34 -6
  129. core/models/resume.py +54 -0
  130. core/plugins/__init__.py +4 -2
  131. core/plugins/base.py +127 -0
  132. core/plugins/collector.py +23 -161
  133. core/plugins/discovery.py +37 -3
  134. core/plugins/factory.py +6 -12
  135. core/plugins/registry.py +5 -17
  136. core/ui/config_widgets.py +128 -28
  137. core/ui/live_modal_renderer.py +2 -1
  138. core/ui/modal_actions.py +5 -0
  139. core/ui/modal_overlay_renderer.py +0 -60
  140. core/ui/modal_renderer.py +268 -7
  141. core/ui/modal_state_manager.py +29 -4
  142. core/ui/widgets/base_widget.py +7 -0
  143. core/updates/__init__.py +10 -0
  144. core/updates/version_check_service.py +348 -0
  145. core/updates/version_comparator.py +103 -0
  146. core/utils/config_utils.py +685 -526
  147. core/utils/plugin_utils.py +1 -1
  148. core/utils/session_naming.py +111 -0
  149. fonts/LICENSE +21 -0
  150. fonts/README.md +46 -0
  151. fonts/SymbolsNerdFont-Regular.ttf +0 -0
  152. fonts/SymbolsNerdFontMono-Regular.ttf +0 -0
  153. fonts/__init__.py +44 -0
  154. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/METADATA +54 -4
  155. kollabor-0.4.15.dist-info/RECORD +228 -0
  156. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/top_level.txt +2 -0
  157. plugins/agent_orchestrator/__init__.py +39 -0
  158. plugins/agent_orchestrator/activity_monitor.py +181 -0
  159. plugins/agent_orchestrator/file_attacher.py +77 -0
  160. plugins/agent_orchestrator/message_injector.py +135 -0
  161. plugins/agent_orchestrator/models.py +48 -0
  162. plugins/agent_orchestrator/orchestrator.py +403 -0
  163. plugins/agent_orchestrator/plugin.py +976 -0
  164. plugins/agent_orchestrator/xml_parser.py +191 -0
  165. plugins/agent_orchestrator_plugin.py +9 -0
  166. plugins/enhanced_input/box_styles.py +1 -0
  167. plugins/enhanced_input/color_engine.py +19 -4
  168. plugins/enhanced_input/config.py +2 -2
  169. plugins/enhanced_input_plugin.py +61 -11
  170. plugins/fullscreen/__init__.py +6 -2
  171. plugins/fullscreen/example_plugin.py +1035 -222
  172. plugins/fullscreen/setup_wizard_plugin.py +592 -0
  173. plugins/fullscreen/space_shooter_plugin.py +131 -0
  174. plugins/hook_monitoring_plugin.py +436 -78
  175. plugins/query_enhancer_plugin.py +66 -30
  176. plugins/resume_conversation_plugin.py +1494 -0
  177. plugins/save_conversation_plugin.py +98 -32
  178. plugins/system_commands_plugin.py +70 -56
  179. plugins/tmux_plugin.py +154 -78
  180. plugins/workflow_enforcement_plugin.py +94 -92
  181. system_prompt/default.md +952 -886
  182. core/io/input_mode_manager.py +0 -402
  183. core/io/modal_interaction_handler.py +0 -315
  184. core/io/raw_input_processor.py +0 -946
  185. core/storage/__init__.py +0 -5
  186. core/storage/state_manager.py +0 -84
  187. core/ui/widget_integration.py +0 -222
  188. core/utils/key_reader.py +0 -171
  189. kollabor-0.4.9.dist-info/RECORD +0 -128
  190. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/WHEEL +0 -0
  191. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/entry_points.txt +0 -0
  192. {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()