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
core/ui/config_widgets.py CHANGED
@@ -209,58 +209,158 @@ class ConfigWidgetDefinitions:
209
209
  ]
210
210
  },
211
211
  {
212
- "title": "LLM Settings",
212
+ "title": "Application Settings",
213
213
  "widgets": [
214
214
  {
215
215
  "type": "text_input",
216
- "label": "API URL",
217
- "config_path": "core.llm.api_url",
218
- "placeholder": "http://localhost:1234",
219
- "help": "LLM API endpoint URL"
216
+ "label": "Application Name",
217
+ "config_path": "application.name",
218
+ "placeholder": "Kollabor CLI",
219
+ "help": "Display name for the application"
220
220
  },
221
221
  {
222
222
  "type": "text_input",
223
- "label": "Model",
224
- "config_path": "core.llm.model",
225
- "placeholder": "qwen/qwen3-4b",
226
- "help": "LLM model identifier"
223
+ "label": "Version",
224
+ "config_path": "application.version",
225
+ "placeholder": "1.0.0",
226
+ "help": "Current application version"
227
+ }
228
+ ]
229
+ },
230
+ {
231
+ "title": "LLM Settings",
232
+ "widgets": [
233
+ {
234
+ "type": "slider",
235
+ "label": "Max History",
236
+ "config_path": "core.llm.max_history",
237
+ "min_value": 10,
238
+ "max_value": 200,
239
+ "step": 10,
240
+ "help": "Maximum conversation history entries to keep"
241
+ },
242
+ {
243
+ "type": "slider",
244
+ "label": "Message History Limit",
245
+ "config_path": "core.llm.message_history_limit",
246
+ "min_value": 5,
247
+ "max_value": 100,
248
+ "step": 5,
249
+ "help": "Messages sent to API per request"
250
+ },
251
+ {
252
+ "type": "checkbox",
253
+ "label": "Enable Streaming",
254
+ "config_path": "core.llm.enable_streaming",
255
+ "help": "Stream responses as they arrive"
227
256
  },
228
257
  {
229
258
  "type": "slider",
230
- "label": "Temperature",
231
- "config_path": "core.llm.temperature",
259
+ "label": "Processing Delay (sec)",
260
+ "config_path": "core.llm.processing_delay",
232
261
  "min_value": 0.0,
233
- "max_value": 2.0,
262
+ "max_value": 1.0,
234
263
  "step": 0.1,
235
- "help": "Creativity/randomness of responses (0.0-2.0)"
264
+ "help": "Delay between processing steps"
236
265
  },
237
266
  {
238
267
  "type": "slider",
239
- "label": "Max History",
240
- "config_path": "core.llm.max_history",
268
+ "label": "Thinking Delay (sec)",
269
+ "config_path": "core.llm.thinking_delay",
270
+ "min_value": 0.0,
271
+ "max_value": 1.0,
272
+ "step": 0.1,
273
+ "help": "Delay for thinking animation display"
274
+ }
275
+ ]
276
+ },
277
+ {
278
+ "title": "Tool Execution Timeouts",
279
+ "widgets": [
280
+ {
281
+ "type": "slider",
282
+ "label": "Terminal Timeout (sec)",
283
+ "config_path": "core.llm.terminal_timeout",
241
284
  "min_value": 10,
242
- "max_value": 200,
285
+ "max_value": 300,
243
286
  "step": 10,
244
- "help": "Maximum conversation history entries"
287
+ "help": "Timeout for terminal commands in seconds"
288
+ },
289
+ {
290
+ "type": "slider",
291
+ "label": "MCP Timeout (sec)",
292
+ "config_path": "core.llm.mcp_timeout",
293
+ "min_value": 10,
294
+ "max_value": 300,
295
+ "step": 10,
296
+ "help": "Timeout for MCP tool calls in seconds"
245
297
  }
246
298
  ]
247
299
  },
248
300
  {
249
- "title": "Application Settings",
301
+ "title": "Logging",
250
302
  "widgets": [
251
303
  {
252
- "type": "text_input",
253
- "label": "Application Name",
254
- "config_path": "application.name",
255
- "placeholder": "Kollabor CLI",
256
- "help": "Display name for the application"
304
+ "type": "dropdown",
305
+ "label": "Log Level",
306
+ "config_path": "logging.level",
307
+ "options": ["DEBUG", "INFO", "WARNING", "ERROR"],
308
+ "help": "Application logging verbosity"
309
+ }
310
+ ]
311
+ },
312
+ {
313
+ "title": "Hooks",
314
+ "widgets": [
315
+ {
316
+ "type": "slider",
317
+ "label": "Default Timeout (sec)",
318
+ "config_path": "hooks.default_timeout",
319
+ "min_value": 5,
320
+ "max_value": 120,
321
+ "step": 5,
322
+ "help": "Default hook execution timeout"
257
323
  },
258
324
  {
259
- "type": "text_input",
260
- "label": "Version",
261
- "config_path": "application.version",
262
- "placeholder": "1.0.0",
263
- "help": "Current application version"
325
+ "type": "slider",
326
+ "label": "Default Retries",
327
+ "config_path": "hooks.default_retries",
328
+ "min_value": 0,
329
+ "max_value": 10,
330
+ "step": 1,
331
+ "help": "Number of retry attempts for failed hooks"
332
+ }
333
+ ]
334
+ },
335
+ {
336
+ "title": "Performance Thresholds",
337
+ "widgets": [
338
+ {
339
+ "type": "slider",
340
+ "label": "Failure Rate Warning",
341
+ "config_path": "performance.failure_rate_warning",
342
+ "min_value": 0.01,
343
+ "max_value": 0.5,
344
+ "step": 0.01,
345
+ "help": "Failure rate to trigger warning (0.05 = 5%)"
346
+ },
347
+ {
348
+ "type": "slider",
349
+ "label": "Failure Rate Critical",
350
+ "config_path": "performance.failure_rate_critical",
351
+ "min_value": 0.05,
352
+ "max_value": 0.5,
353
+ "step": 0.01,
354
+ "help": "Failure rate to trigger critical alert (0.15 = 15%)"
355
+ },
356
+ {
357
+ "type": "slider",
358
+ "label": "Degradation Threshold",
359
+ "config_path": "performance.degradation_threshold",
360
+ "min_value": 0.05,
361
+ "max_value": 0.5,
362
+ "step": 0.01,
363
+ "help": "Performance degradation threshold (0.15 = 15%)"
264
364
  }
265
365
  ]
266
366
  },
@@ -75,9 +75,10 @@ class LiveModalRenderer:
75
75
  width, height = self.terminal_state.get_size()
76
76
 
77
77
  # Create layout for fullscreen modal
78
+ # Use most of the screen, minimal margin for header/footer
78
79
  layout = ModalLayout(
79
80
  width=width - 4, # Leave margin
80
- height=height - 2,
81
+ height=height - 4, # Minimal margin for borders
81
82
  start_row=1,
82
83
  start_col=2,
83
84
  center_horizontal=True,
core/ui/modal_actions.py CHANGED
@@ -91,6 +91,11 @@ class ModalActionHandler:
91
91
 
92
92
  if success:
93
93
  logger.info(f"Successfully saved {len(changes)} configuration changes")
94
+
95
+ # Trigger hot reload for all registered services
96
+ self.config_service._notify_reload_callbacks()
97
+ logger.info("Hot reload callbacks triggered")
98
+
94
99
  return {
95
100
  "success": True,
96
101
  "message": f"Saved {len(changes)} configuration changes",
@@ -311,63 +311,3 @@ class ModalOverlayRenderer:
311
311
  "has_saved_state": self.saved_state is not None,
312
312
  "terminal_size": self.terminal_state.get_size()
313
313
  }
314
-
315
-
316
- class ModalDisplayCoordinator:
317
- """Coordinates modal display with input system without chat interference.
318
-
319
- This coordinator ensures modal display updates happen through
320
- the overlay system rather than the conversation pipeline.
321
- """
322
-
323
- def __init__(self, modal_overlay_renderer: ModalOverlayRenderer):
324
- """Initialize modal display coordinator.
325
-
326
- Args:
327
- modal_overlay_renderer: ModalOverlayRenderer instance.
328
- """
329
- self.overlay_renderer = modal_overlay_renderer
330
- self.event_handlers = {}
331
-
332
- def register_modal_event_handler(self, event_type: str, handler) -> None:
333
- """Register event handler for modal interactions.
334
-
335
- Args:
336
- event_type: Type of event to handle.
337
- handler: Event handler function.
338
- """
339
- self.event_handlers[event_type] = handler
340
-
341
- def handle_modal_widget_change(self, widget_data: Dict[str, Any]) -> bool:
342
- """Handle widget state change in modal.
343
-
344
- Args:
345
- widget_data: Widget state change information.
346
-
347
- Returns:
348
- True if change was handled successfully.
349
- """
350
- try:
351
- # Trigger modal refresh through overlay system (not chat system)
352
- return self.overlay_renderer.refresh_modal_display()
353
-
354
- except Exception as e:
355
- logger.error(f"Failed to handle modal widget change: {e}")
356
- return False
357
-
358
- def handle_modal_navigation(self, navigation_data: Dict[str, Any]) -> bool:
359
- """Handle navigation in modal (arrow keys, tab, etc.).
360
-
361
- Args:
362
- navigation_data: Navigation event information.
363
-
364
- Returns:
365
- True if navigation was handled successfully.
366
- """
367
- try:
368
- # Process navigation and refresh modal display
369
- return self.overlay_renderer.refresh_modal_display()
370
-
371
- except Exception as e:
372
- logger.error(f"Failed to handle modal navigation: {e}")
373
- return False
core/ui/modal_renderer.py CHANGED
@@ -16,6 +16,9 @@ from .modal_state_manager import ModalStateManager, ModalLayout, ModalDisplayMod
16
16
 
17
17
  logger = logging.getLogger(__name__)
18
18
 
19
+ # Maximum modal width to prevent overly wide modals
20
+ MAX_MODAL_WIDTH = 80
21
+
19
22
 
20
23
  class ModalRenderer:
21
24
  """Modal overlay renderer using existing visual effects system."""
@@ -49,6 +52,11 @@ class ModalRenderer:
49
52
  self.visible_height = 20 # Number of widget lines visible at once
50
53
  self._save_confirm_active = False # For save confirmation prompt
51
54
 
55
+ # Command list selection (for modals with "commands" sections)
56
+ self.command_items: List[Dict] = [] # Flat list of all command items
57
+ self.selected_command_index = 0
58
+ self.has_command_sections = False
59
+
52
60
  # Action handling
53
61
  self.action_handler = ModalActionHandler(config_service) if config_service else None
54
62
 
@@ -62,6 +70,9 @@ class ModalRenderer:
62
70
  Modal interaction result.
63
71
  """
64
72
  try:
73
+ # Reset command selection state for fresh modal
74
+ self._command_selected = False
75
+
65
76
  # FIXED: Use overlay system instead of chat pipeline clearing
66
77
  # No more clear_active_area() - that only clears display, not buffers
67
78
 
@@ -130,9 +141,10 @@ class ModalRenderer:
130
141
  border_color = ColorPalette.GREY
131
142
  title_color = ColorPalette.BRIGHT_WHITE
132
143
  footer_color = ColorPalette.GREY
133
- # Use dynamic terminal width instead of hardcoded values
144
+ # Use dynamic terminal width, capped at MAX_MODAL_WIDTH (80 cols)
134
145
  terminal_width = getattr(self.terminal_renderer.terminal_state, 'width', 80) if self.terminal_renderer else 80
135
- width = min(int(ui_config.width or terminal_width), terminal_width)
146
+ requested_width = int(ui_config.width or MAX_MODAL_WIDTH)
147
+ width = min(requested_width, terminal_width, MAX_MODAL_WIDTH)
136
148
  title = ui_config.title or "Modal"
137
149
 
138
150
  lines = []
@@ -176,6 +188,9 @@ class ModalRenderer:
176
188
  Returns:
177
189
  List of content lines with rendered widgets.
178
190
  """
191
+ # Store config for scroll calculations (needed for non-selectable items)
192
+ self._last_modal_config = modal_config
193
+
179
194
  all_lines = [] # All content lines before pagination
180
195
  border_color = ColorPalette.GREY # Modal border color
181
196
 
@@ -187,6 +202,12 @@ class ModalRenderer:
187
202
  self.widgets = self._create_widgets(modal_config)
188
203
  if self.widgets:
189
204
  self.widgets[0].set_focus(True)
205
+ # Reset command selection for command-style modals
206
+ self.selected_command_index = 0
207
+
208
+ # Always rebuild command_items list (but preserve selected_command_index)
209
+ self.command_items = []
210
+ self.has_command_sections = False
190
211
 
191
212
  # Build all content lines with widget indices
192
213
  widget_index = 0
@@ -220,6 +241,103 @@ class ModalRenderer:
220
241
 
221
242
  widget_index += 1
222
243
 
244
+ # Handle "commands" format (used by help modal, etc.)
245
+ section_commands = section.get("commands", [])
246
+ if section_commands and not section_widgets:
247
+ self.has_command_sections = True
248
+ for cmd_idx, cmd in enumerate(section_commands):
249
+ name = cmd.get("name", "")
250
+ description = cmd.get("description", "")
251
+ is_selectable = cmd.get("selectable", True) # Default to selectable
252
+
253
+ # Truncate name and description BEFORE adding ANSI codes
254
+ # Layout: " > name description" = ~34 chars prefix + description
255
+ max_name_len = 26
256
+ if len(name) > max_name_len:
257
+ name = name[:max_name_len - 3] + "..."
258
+
259
+ max_desc_len = width - 38 # Account for prefix, name padding, and borders
260
+ if len(description) > max_desc_len:
261
+ description = description[:max_desc_len - 3] + "..."
262
+
263
+ if is_selectable:
264
+ # Track command item with its global index (only for selectable items)
265
+ global_cmd_idx = len(self.command_items)
266
+ self.command_items.append(cmd)
267
+
268
+ # Check if this item is selected
269
+ is_selected = (global_cmd_idx == self.selected_command_index)
270
+
271
+ # Format command line with selection indicator
272
+ if is_selected:
273
+ # Highlight selected item with lime color
274
+ cmd_text = f" {ColorPalette.LIME_LIGHT}> {name:<26}{ColorPalette.RESET} {description}"
275
+ else:
276
+ cmd_text = f" {name:<26} {description}"
277
+ widget_line_map.append(-2) # Command entry (use -2 to distinguish from headers)
278
+ else:
279
+ # Non-selectable info item - render dimmed
280
+ cmd_text = f" {ColorPalette.DIM}{name:<26} {description}{ColorPalette.RESET}"
281
+ widget_line_map.append(-4) # Non-selectable info line
282
+
283
+ modal_line = f"│{self._pad_line_with_ansi(cmd_text, width-2)}│"
284
+ all_lines.append(f"{border_color}{modal_line}{ColorPalette.RESET}")
285
+
286
+ # Also handle "sessions" format (used by resume modal, etc.)
287
+ section_sessions = section.get("sessions", [])
288
+ if section_sessions and not section_widgets and not section_commands:
289
+ self.has_command_sections = True
290
+ for sess_idx, sess in enumerate(section_sessions):
291
+ # Track session item with its global index
292
+ global_sess_idx = len(self.command_items)
293
+ # Convert session format to command format for selection handling
294
+ cmd_item = {
295
+ "name": sess.get("title", sess.get("id", "Unknown")),
296
+ "description": sess.get("subtitle", ""),
297
+ "session_id": sess.get("id") or sess.get("metadata", {}).get("session_id", ""),
298
+ "action": sess.get("action", "resume_session"), # Use session's action or default
299
+ "exit_mode": sess.get("exit_mode", "normal"), # How to exit modal before handling
300
+ "metadata": sess.get("metadata", {})
301
+ }
302
+ self.command_items.append(cmd_item)
303
+
304
+ title = sess.get("title", sess.get("id", "Unknown"))
305
+ subtitle = sess.get("subtitle", "")
306
+
307
+ # Check if this item is selected
308
+ is_selected = (global_sess_idx == self.selected_command_index)
309
+
310
+ # Format session line with selection indicator
311
+ # Truncate title BEFORE adding ANSI codes to avoid mid-code truncation
312
+ max_title_len = width - 8 # Account for " > " prefix and borders
313
+ if len(title) > max_title_len:
314
+ title = title[:max_title_len - 3] + "..."
315
+
316
+ if is_selected:
317
+ # Highlight selected item with lime color
318
+ sess_text = f" {ColorPalette.LIME_LIGHT}> {title}{ColorPalette.RESET}"
319
+ else:
320
+ sess_text = f" {title}"
321
+
322
+ modal_line = f"│{self._pad_line_with_ansi(sess_text, width-2)}│"
323
+ all_lines.append(f"{border_color}{modal_line}{ColorPalette.RESET}")
324
+ widget_line_map.append(-2) # Session entry
325
+
326
+ # Add subtitle on a second line if present
327
+ if subtitle:
328
+ # Truncate subtitle BEFORE adding ANSI codes
329
+ max_sub_len = width - 10 # Account for " " prefix and borders
330
+ if len(subtitle) > max_sub_len:
331
+ subtitle = subtitle[:max_sub_len - 3] + "..."
332
+
333
+ if is_selected:
334
+ sub_text = f" {ColorPalette.GREY}{subtitle}{ColorPalette.RESET}"
335
+ else:
336
+ sub_text = f" {ColorPalette.DIM}{subtitle}{ColorPalette.RESET}"
337
+ sub_line = f"│{self._pad_line_with_ansi(sub_text, width-2)}│"
338
+ all_lines.append(f"{border_color}{sub_line}{ColorPalette.RESET}")
339
+ widget_line_map.append(-3) # Subtitle line (non-selectable)
340
+
223
341
  # Add blank line after each section (except the last one)
224
342
  if section_idx < len(sections) - 1:
225
343
  blank_line = f"│{' ' * (width-2)}│"
@@ -251,10 +369,20 @@ class ModalRenderer:
251
369
 
252
370
  # Apply scroll offset and return visible lines
253
371
  total_lines = len(all_lines)
372
+
373
+ # Clamp scroll offset to valid range (fixes wrap-around from first to last item)
374
+ max_scroll = max(0, total_lines - self.visible_height)
375
+ self.scroll_offset = max(0, min(self.scroll_offset, max_scroll))
376
+
254
377
  end_offset = min(self.scroll_offset + self.visible_height, total_lines)
255
378
  visible_lines = all_lines[self.scroll_offset:end_offset]
256
379
 
257
- # Add scroll indicator if needed
380
+ # Pad to fixed height (visible_height) to prevent height changes when scrolling
381
+ while len(visible_lines) < self.visible_height:
382
+ empty_line = f"│{' ' * (width-2)}│"
383
+ visible_lines.append(f"{border_color}{empty_line}{ColorPalette.RESET}")
384
+
385
+ # Add scroll indicator if needed (always at the same position)
258
386
  if total_lines > self.visible_height:
259
387
  scroll_info = f" [{self.scroll_offset + 1}-{end_offset}/{total_lines}] "
260
388
  if self.scroll_offset > 0:
@@ -263,6 +391,10 @@ class ModalRenderer:
263
391
  scroll_info = f"{scroll_info}↓"
264
392
  indicator_line = f"│{scroll_info.center(width-2)}│"
265
393
  visible_lines.append(f"{ColorPalette.DIM}{indicator_line}{ColorPalette.RESET}")
394
+ else:
395
+ # Add empty indicator line to maintain fixed height
396
+ empty_indicator = f"│{' ' * (width-2)}│"
397
+ visible_lines.append(f"{border_color}{empty_indicator}{ColorPalette.RESET}")
266
398
 
267
399
  return visible_lines
268
400
 
@@ -291,10 +423,12 @@ class ModalRenderer:
291
423
  # This completely bypasses write_message() and conversation buffers
292
424
 
293
425
  # Create modal layout configuration
294
- content_width = max(len(line) for line in lines) if lines else 80
295
- # Constrain to terminal width, leaving space for borders
426
+ # Use visible width (strip ANSI) for accurate layout calculation
427
+ visible_widths = [len(self._strip_ansi(line)) for line in lines] if lines else [MAX_MODAL_WIDTH]
428
+ content_width = max(visible_widths)
429
+ # Constrain to terminal width and MAX_MODAL_WIDTH (80 cols)
296
430
  terminal_width = getattr(self.terminal_renderer.terminal_state, 'width', 80) if self.terminal_renderer else 80
297
- width = min(content_width + 4, terminal_width - 2) # Add padding but leave space for borders
431
+ width = min(content_width, terminal_width - 2, MAX_MODAL_WIDTH) # Cap at 80 cols
298
432
  height = len(lines)
299
433
  layout = ModalLayout(
300
434
  width=width,
@@ -379,7 +513,8 @@ class ModalRenderer:
379
513
  logger.error(f"Widget config missing 'type' field: {e}")
380
514
  raise ValueError(f"Widget config missing required 'type' field: {config}")
381
515
 
382
- config_path = config.get("config_path", "core.ui.unknown")
516
+ # Support both "config_path" (for config-bound widgets) and "field" (for form modals)
517
+ config_path = config.get("config_path") or config.get("field", "core.ui.unknown")
383
518
 
384
519
  # Get current value from config service if available
385
520
  current_value = None
@@ -440,6 +575,68 @@ class ModalRenderer:
440
575
  Returns:
441
576
  True if navigation was handled.
442
577
  """
578
+ # Handle command-style modal navigation (no widgets, just command items)
579
+ if self.has_command_sections and self.command_items and not self.widgets:
580
+ old_index = self.selected_command_index
581
+ lines_per_item = 2 # Approximate lines per command item
582
+
583
+ # Calculate total content height (including non-selectable items)
584
+ # Use a larger estimate to account for section headers and non-selectable items
585
+ total_content_lines = self._estimate_total_content_lines()
586
+ max_scroll = max(0, total_content_lines - self.visible_height)
587
+
588
+ if key_press.name == "ArrowDown" or key_press.name == "Tab":
589
+ if self.selected_command_index < len(self.command_items) - 1:
590
+ # Move to next selectable item
591
+ self.selected_command_index += 1
592
+ elif self.scroll_offset < max_scroll:
593
+ # At last selectable item but more content below - just scroll
594
+ self.scroll_offset = min(self.scroll_offset + 2, max_scroll)
595
+ return True # Don't change selection, just scrolled
596
+ else:
597
+ # At bottom of content - wrap to top
598
+ self.selected_command_index = 0
599
+ self.scroll_offset = 0
600
+ return True
601
+ elif key_press.name == "ArrowUp":
602
+ if self.selected_command_index > 0:
603
+ # Move to previous selectable item
604
+ self.selected_command_index -= 1
605
+ elif self.scroll_offset > 0:
606
+ # At first selectable item but can scroll up - just scroll
607
+ self.scroll_offset = max(0, self.scroll_offset - 2)
608
+ return True # Don't change selection, just scrolled
609
+ else:
610
+ # At top of content - wrap to bottom
611
+ self.selected_command_index = len(self.command_items) - 1
612
+ self.scroll_offset = max_scroll
613
+ return True
614
+ elif key_press.name == "PageDown":
615
+ self.selected_command_index = min(self.selected_command_index + 10, len(self.command_items) - 1)
616
+ # Also scroll to bottom if at last item
617
+ if self.selected_command_index == len(self.command_items) - 1:
618
+ self.scroll_offset = max_scroll
619
+ elif key_press.name == "PageUp":
620
+ self.selected_command_index = max(self.selected_command_index - 10, 0)
621
+ # Also scroll to top if at first item
622
+ if self.selected_command_index == 0:
623
+ self.scroll_offset = 0
624
+ else:
625
+ return False
626
+
627
+ # Update scroll offset to keep selection visible
628
+ selected_line = self.selected_command_index * lines_per_item
629
+
630
+ # Scroll down if selection is below visible area
631
+ if selected_line >= self.scroll_offset + self.visible_height - 2:
632
+ self.scroll_offset = max(0, selected_line - self.visible_height + 4)
633
+
634
+ # Scroll up if selection is above visible area
635
+ if selected_line < self.scroll_offset:
636
+ self.scroll_offset = max(0, selected_line - 2)
637
+
638
+ return True
639
+
443
640
  if not self.widgets:
444
641
  return False
445
642
 
@@ -490,6 +687,17 @@ class ModalRenderer:
490
687
  Returns:
491
688
  True if input was handled by a widget.
492
689
  """
690
+ # Handle Enter key for command-style modals
691
+ logger.info(f"🔧 _handle_widget_input: has_command_sections={self.has_command_sections}, "
692
+ f"command_items={len(self.command_items) if self.command_items else 0}, "
693
+ f"widgets={len(self.widgets) if self.widgets else 0}")
694
+ if self.has_command_sections and self.command_items and not self.widgets:
695
+ if key_press.name == "Enter" or key_press.char == "\r":
696
+ # Mark that a command was selected
697
+ self._command_selected = True
698
+ logger.info(f"🎯 Command selected! _command_selected={self._command_selected}")
699
+ return True
700
+ return False
493
701
 
494
702
  if not self.widgets or self.focused_widget_index >= len(self.widgets):
495
703
  return False
@@ -499,6 +707,25 @@ class ModalRenderer:
499
707
  result = focused_widget.handle_input(key_press)
500
708
  return result
501
709
 
710
+ def get_selected_command(self) -> Optional[Dict]:
711
+ """Get the currently selected command item.
712
+
713
+ Returns:
714
+ Selected command dict or None.
715
+ """
716
+ if self.has_command_sections and self.command_items:
717
+ if 0 <= self.selected_command_index < len(self.command_items):
718
+ return self.command_items[self.selected_command_index]
719
+ return None
720
+
721
+ def was_command_selected(self) -> bool:
722
+ """Check if a command was selected via Enter.
723
+
724
+ Returns:
725
+ True if a command was selected.
726
+ """
727
+ return getattr(self, '_command_selected', False)
728
+
502
729
  def _get_widget_values(self) -> Dict[str, Any]:
503
730
  """Get all widget values for saving.
504
731
 
@@ -545,6 +772,40 @@ class ModalRenderer:
545
772
  """
546
773
  return re.sub(r'\033\[[0-9;]*m', '', text)
547
774
 
775
+ def _estimate_total_content_lines(self) -> int:
776
+ """Estimate total content lines including non-selectable items.
777
+
778
+ Used for scroll calculations when there are non-selectable items
779
+ at the end of the modal content.
780
+
781
+ Returns:
782
+ Estimated total number of content lines.
783
+ """
784
+ if not hasattr(self, '_last_modal_config') or not self._last_modal_config:
785
+ # Fallback: use command items count * 2 (approximate)
786
+ return len(self.command_items) * 2 if self.command_items else 0
787
+
788
+ total_lines = 0
789
+ sections = self._last_modal_config.get("sections", [])
790
+
791
+ for section in sections:
792
+ # Section header
793
+ if section.get("title"):
794
+ total_lines += 1
795
+
796
+ # Count all commands (selectable and non-selectable)
797
+ commands = section.get("commands", [])
798
+ total_lines += len(commands)
799
+
800
+ # Count sessions
801
+ sessions = section.get("sessions", [])
802
+ total_lines += len(sessions)
803
+
804
+ # Blank line between sections
805
+ total_lines += 1
806
+
807
+ return total_lines
808
+
548
809
  def _pad_line_with_ansi(self, line: str, target_width: int) -> str:
549
810
  """Pad line to target width, accounting for ANSI escape codes.
550
811