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
@@ -1,11 +1,86 @@
1
1
  """Command menu renderer for interactive slash command display."""
2
2
 
3
3
  import logging
4
- from typing import List, Dict, Any, Optional
4
+ from typing import List, Dict, Any, Optional, Tuple
5
5
  from ..events.models import CommandCategory
6
+ from ..io.visual_effects import (
7
+ AgnosterSegment, AgnosterColors, ColorPalette,
8
+ make_bg_color, make_fg_color, Powerline,
9
+ get_color_support, ColorSupport
10
+ )
6
11
 
7
12
  logger = logging.getLogger(__name__)
8
13
 
14
+ # Basic ANSI codes
15
+ BOLD = "\033[1m"
16
+ DIM = "\033[2m"
17
+ RESET = ColorPalette.RESET
18
+
19
+ # Powerline characters
20
+ PL_RIGHT = "" # \ue0b0
21
+
22
+ # Menu symbols
23
+ ARROW_RIGHT = "▶"
24
+ ARROW_DOWN = "▼"
25
+ ARROW_UP = "▲"
26
+ DOT = "·"
27
+ GLOW = "◆"
28
+
29
+ # Category display order and icons (Unicode symbols)
30
+ CATEGORY_CONFIG = {
31
+ "system": {"name": "SYS", "icon": "⚙", "full": "System"},
32
+ "conversation": {"name": "CHAT", "icon": "⌘", "full": "Conversation"},
33
+ "agent": {"name": "AGENT", "icon": "◈", "full": "Agent"},
34
+ "development": {"name": "DEV", "icon": "⌥", "full": "Development"},
35
+ "file": {"name": "FILE", "icon": "≡", "full": "Files"},
36
+ "task": {"name": "TASK", "icon": "☰", "full": "Tasks"},
37
+ "custom": {"name": "PLUG", "icon": "⊕", "full": "Plugins"},
38
+ }
39
+ CATEGORY_ORDER = ["system", "conversation", "agent", "development", "file", "task", "custom"]
40
+
41
+
42
+ def apply_bg_gradient(
43
+ text: str,
44
+ start_color: Tuple[int, int, int],
45
+ end_color: Tuple[int, int, int],
46
+ ) -> str:
47
+ """Apply background color gradient to text.
48
+
49
+ Args:
50
+ text: Text to apply gradient background to.
51
+ start_color: RGB tuple for start of gradient.
52
+ end_color: RGB tuple for end of gradient.
53
+
54
+ Returns:
55
+ Text with gradient background applied.
56
+ """
57
+ if not text:
58
+ return text
59
+
60
+ result = []
61
+ text_length = len(text)
62
+ use_true_color = get_color_support() == ColorSupport.TRUE_COLOR
63
+
64
+ for i, char in enumerate(text):
65
+ position = i / max(1, text_length - 1)
66
+
67
+ # Interpolate between start and end colors
68
+ r = int(start_color[0] + (end_color[0] - start_color[0]) * position)
69
+ g = int(start_color[1] + (end_color[1] - start_color[1]) * position)
70
+ b = int(start_color[2] + (end_color[2] - start_color[2]) * position)
71
+
72
+ if use_true_color:
73
+ bg_code = f"\033[48;2;{r};{g};{b}m"
74
+ else:
75
+ # Fallback to 256-color approximation
76
+ color_idx = 16 + (36 * (r // 51)) + (6 * (g // 51)) + (b // 51)
77
+ bg_code = f"\033[48;5;{color_idx}m"
78
+
79
+ result.append(f"{bg_code}{char}")
80
+
81
+ result.append(RESET)
82
+ return "".join(result)
83
+
9
84
 
10
85
  class CommandMenuRenderer:
11
86
  """Renders interactive command menu overlay.
@@ -14,19 +89,51 @@ class CommandMenuRenderer:
14
89
  types '/' and allows filtering and selection of available commands.
15
90
  """
16
91
 
17
- def __init__(self, terminal_renderer) -> None:
92
+ def __init__(self, terminal_renderer, max_visible_items: int = 5) -> None:
18
93
  """Initialize the command menu renderer.
19
94
 
20
95
  Args:
21
96
  terminal_renderer: Terminal renderer for display operations.
97
+ max_visible_items: Maximum number of menu items to show at once.
22
98
  """
23
99
  self.renderer = terminal_renderer
24
100
  self.logger = logger
25
101
  self.menu_active = False
26
102
  self.current_commands = []
103
+ self.menu_items = [] # Flattened list: commands + subcommands as selectable items
27
104
  self.selected_index = 0
28
105
  self.filter_text = ""
29
106
  self.current_menu_lines = [] # Store menu content for event system
107
+ self.max_visible_items = max_visible_items
108
+ self.scroll_offset = 0 # First visible item index
109
+
110
+ def _get_menu_width(self) -> int:
111
+ """Get menu width to match input box width.
112
+
113
+ Uses same calculation as enhanced_input plugin:
114
+ terminal_width - 4, clamped between min_width and max_width from config.
115
+
116
+ Returns:
117
+ Menu width in characters.
118
+ """
119
+ import shutil
120
+ try:
121
+ terminal_width = shutil.get_terminal_size().columns
122
+ except Exception:
123
+ terminal_width = 80
124
+
125
+ # Read constraints from same config as enhanced_input plugin
126
+ config = getattr(self.renderer, '_app_config', None)
127
+ if config and hasattr(config, 'get'):
128
+ min_width = config.get("plugins.enhanced_input.min_width", 60)
129
+ max_width = config.get("plugins.enhanced_input.max_width", 120)
130
+ else:
131
+ min_width = 60
132
+ max_width = 120
133
+
134
+ # Match input box: terminal_width - 4, with constraints
135
+ proposed_width = terminal_width - 4
136
+ return max(min_width, min(max_width, proposed_width))
30
137
 
31
138
  def show_command_menu(self, commands: List[Dict[str, Any]], filter_text: str = "") -> None:
32
139
  """Display command menu when user types '/'.
@@ -37,28 +144,33 @@ class CommandMenuRenderer:
37
144
  """
38
145
  try:
39
146
  self.menu_active = True
40
- self.current_commands = commands
147
+ self.current_commands = self._sort_commands_by_category(commands)
41
148
  self.filter_text = filter_text
42
149
  self.selected_index = 0
150
+ self.scroll_offset = 0 # Reset scroll when menu opens
151
+
152
+ # Build flattened menu items (commands + subcommands)
153
+ self.menu_items = self._build_menu_items()
43
154
 
44
155
  # Render the menu
45
156
  self._render_menu()
46
157
 
47
- self.logger.info(f"Command menu shown with {len(commands)} commands")
158
+ self.logger.info(f"Command menu shown with {len(commands)} commands, {len(self.menu_items)} items")
48
159
 
49
160
  except Exception as e:
50
161
  self.logger.error(f"Error showing command menu: {e}")
51
162
 
52
163
  def set_selected_index(self, index: int) -> None:
53
- """Set the selected command index for navigation.
164
+ """Set the selected menu item index for navigation.
54
165
 
55
166
  Args:
56
- index: Index of the command to select.
167
+ index: Index of the item to select (in menu_items list).
57
168
  """
58
- if 0 <= index < len(self.current_commands):
169
+ if 0 <= index < len(self.menu_items):
59
170
  self.selected_index = index
171
+ self._ensure_selection_visible()
60
172
  # Note: No auto-render here - caller will trigger render to avoid duplicates
61
- logger.debug(f"Selected command index set to: {index}")
173
+ logger.debug(f"Selected menu item index set to: {index}")
62
174
 
63
175
  def hide_menu(self) -> None:
64
176
  """Hide command menu and return to normal input."""
@@ -68,6 +180,7 @@ class CommandMenuRenderer:
68
180
  self.current_commands = []
69
181
  self.selected_index = 0
70
182
  self.filter_text = ""
183
+ self.scroll_offset = 0
71
184
 
72
185
  # Clear menu from display
73
186
  self._clear_menu()
@@ -89,16 +202,22 @@ class CommandMenuRenderer:
89
202
  if not self.menu_active:
90
203
  return
91
204
 
92
- self.current_commands = commands
205
+ self.current_commands = self._sort_commands_by_category(commands)
93
206
  self.filter_text = filter_text
94
207
 
208
+ # Build flattened menu items (commands + subcommands)
209
+ self.menu_items = self._build_menu_items()
210
+
95
211
  # Only reset selection when filtering by typing, not during navigation
96
212
  if reset_selection:
97
213
  self.selected_index = 0 # Reset selection to top
214
+ self.scroll_offset = 0 # Reset scroll when filtering
98
215
  else:
99
216
  # Ensure selected index is still valid after filtering
100
- if self.selected_index >= len(commands):
101
- self.selected_index = max(0, len(commands) - 1)
217
+ if self.selected_index >= len(self.menu_items):
218
+ self.selected_index = max(0, len(self.menu_items) - 1)
219
+ # Adjust scroll if needed
220
+ self._ensure_selection_visible()
102
221
 
103
222
  # Re-render with filtered commands
104
223
  self._render_menu()
@@ -118,16 +237,19 @@ class CommandMenuRenderer:
118
237
  True if navigation was handled, False otherwise.
119
238
  """
120
239
  try:
121
- if not self.menu_active or not self.current_commands:
240
+ if not self.menu_active or not self.menu_items:
122
241
  return False
123
242
 
124
243
  if direction == "up":
125
244
  self.selected_index = max(0, self.selected_index - 1)
126
245
  elif direction == "down":
127
- self.selected_index = min(len(self.current_commands) - 1, self.selected_index + 1)
246
+ self.selected_index = min(len(self.menu_items) - 1, self.selected_index + 1)
128
247
  else:
129
248
  return False
130
249
 
250
+ # Adjust scroll to keep selection visible
251
+ self._ensure_selection_visible()
252
+
131
253
  # Re-render with new selection
132
254
  self._render_menu()
133
255
  return True
@@ -136,18 +258,76 @@ class CommandMenuRenderer:
136
258
  self.logger.error(f"Error navigating menu: {e}")
137
259
  return False
138
260
 
261
+ def _ensure_selection_visible(self) -> None:
262
+ """Adjust scroll offset to keep selected item visible."""
263
+ if not self.menu_items:
264
+ return
265
+
266
+ # If selection is above visible area, scroll up
267
+ if self.selected_index < self.scroll_offset:
268
+ self.scroll_offset = self.selected_index
269
+
270
+ # If selection is below visible area, scroll down
271
+ elif self.selected_index >= self.scroll_offset + self.max_visible_items:
272
+ self.scroll_offset = self.selected_index - self.max_visible_items + 1
273
+
274
+ # Clamp scroll offset to valid range
275
+ max_scroll = max(0, len(self.menu_items) - self.max_visible_items)
276
+ self.scroll_offset = max(0, min(self.scroll_offset, max_scroll))
277
+
139
278
  def get_selected_command(self) -> Optional[Dict[str, Any]]:
140
- """Get currently selected command.
279
+ """Get currently selected menu item (command or subcommand).
141
280
 
142
281
  Returns:
143
- Selected command dictionary or None if no selection.
282
+ Selected item dictionary with keys:
283
+ - For commands: name, description, aliases, category, etc.
284
+ - For subcommands: is_subcommand=True, parent_name, subcommand_name, subcommand_args
285
+ Returns None if no selection.
144
286
  """
145
287
  if (self.menu_active and
146
- self.current_commands and
147
- 0 <= self.selected_index < len(self.current_commands)):
148
- return self.current_commands[self.selected_index]
288
+ self.menu_items and
289
+ 0 <= self.selected_index < len(self.menu_items)):
290
+ return self.menu_items[self.selected_index]
149
291
  return None
150
292
 
293
+ def _build_menu_items(self) -> List[Dict[str, Any]]:
294
+ """Build flattened list of menu items including subcommands.
295
+
296
+ Creates a flat list where each command is followed by its subcommands.
297
+ Subcommands only appear when filtered to a single command (not in main menu).
298
+
299
+ Returns:
300
+ List of menu item dicts, each with is_subcommand flag.
301
+ """
302
+ items = []
303
+ # Only show subcommands when filtered to a single command
304
+ show_subcommands = len(self.current_commands) == 1
305
+
306
+ for i, cmd in enumerate(self.current_commands):
307
+ # Add the command itself
308
+ cmd_item = cmd.copy()
309
+ cmd_item["is_subcommand"] = False
310
+ cmd_item["_cmd_index"] = i
311
+ items.append(cmd_item)
312
+
313
+ # Add subcommands only when this is the only command showing
314
+ if show_subcommands:
315
+ subcommands = cmd.get("subcommands", [])
316
+ if subcommands:
317
+ for sub in subcommands:
318
+ sub_item = {
319
+ "is_subcommand": True,
320
+ "parent_name": cmd["name"],
321
+ "parent_category": cmd.get("category", "custom"),
322
+ "subcommand_name": sub.get("name", ""),
323
+ "subcommand_args": sub.get("args", ""),
324
+ "subcommand_desc": sub.get("description", ""),
325
+ "_cmd_index": i,
326
+ }
327
+ items.append(sub_item)
328
+
329
+ return items
330
+
151
331
  def _render_menu(self) -> None:
152
332
  """Render the command menu overlay."""
153
333
  try:
@@ -164,44 +344,207 @@ class CommandMenuRenderer:
164
344
  self.logger.error(f"Error rendering menu: {e}")
165
345
 
166
346
  def _create_menu_lines(self) -> List[str]:
167
- """Create lines for menu display.
347
+ """Create lines for menu display with category grouping and scroll support.
168
348
 
169
349
  Returns:
170
- List of formatted menu lines.
350
+ List of formatted menu lines (limited to max_visible_items).
171
351
  """
172
352
  lines = []
173
353
 
174
- # Header (input display handled by enhanced input plugin)
175
- # Note: Removed separator line as requested
354
+ # If no items, show empty state
355
+ if not self.menu_items:
356
+ lines.append(self._make_empty_state())
357
+ return lines
176
358
 
177
- # Group commands by category for organized display
178
- categorized_commands = self._group_commands_by_category()
359
+ total_items = len(self.menu_items)
360
+ has_more_above = self.scroll_offset > 0
361
+ has_more_below = self.scroll_offset + self.max_visible_items < total_items
179
362
 
180
- # Render each category
181
- for category, commands in categorized_commands.items():
182
- if not commands:
183
- continue
363
+ # Scroll up indicator
364
+ if has_more_above:
365
+ lines.append(self._make_scroll_indicator("up", self.scroll_offset))
184
366
 
185
- # Category header (if multiple categories)
186
- if len(categorized_commands) > 1:
187
- category_name = self._format_category_name(category)
188
- lines.append(f" {category_name}")
367
+ # Get visible items slice
368
+ visible_start = self.scroll_offset
369
+ visible_end = min(self.scroll_offset + self.max_visible_items, total_items)
189
370
 
190
- # Render commands in this category
191
- for cmd in commands:
192
- line = self._format_command_line(cmd)
371
+ # Track current category for headers
372
+ current_category = None
373
+
374
+ # Render visible items (commands and subcommands)
375
+ for i in range(visible_start, visible_end):
376
+ item = self.menu_items[i]
377
+ is_selected = (i == self.selected_index)
378
+
379
+ if item.get("is_subcommand"):
380
+ # Render subcommand item
381
+ line = self._format_subcommand_item(item, is_selected)
382
+ lines.append(line)
383
+ else:
384
+ # Render command item
385
+ cmd_category = item.get("category", "custom")
386
+
387
+ # Convert CommandCategory enum to string if needed
388
+ if hasattr(cmd_category, 'value'):
389
+ cmd_category = cmd_category.value
390
+
391
+ # Insert category header when category changes
392
+ if cmd_category != current_category:
393
+ current_category = cmd_category
394
+ header = self._format_category_header(cmd_category)
395
+ lines.append(header)
396
+
397
+ item["_is_selected"] = is_selected
398
+ item["_index"] = i
399
+ line = self._format_command_line(item, cmd_category)
193
400
  lines.append(line)
194
401
 
195
- # Add spacing between categories
196
- if len(categorized_commands) > 1:
197
- lines.append("")
402
+ # Scroll down indicator
403
+ if has_more_below:
404
+ remaining = total_items - visible_end
405
+ lines.append(self._make_scroll_indicator("down", remaining))
198
406
 
199
- # If no commands, show message
200
- if not self.current_commands:
201
- lines.append(" No matching commands found")
407
+ # Footer with keybind hints
408
+ lines.extend(self._make_footer())
202
409
 
203
410
  return lines
204
411
 
412
+ def _make_empty_state(self) -> str:
413
+ """Create fancy empty state message."""
414
+ seg = AgnosterSegment()
415
+ seg.add_neutral(f" {GLOW} No matches ", "dark")
416
+ return seg.render(separator="")
417
+
418
+ def _make_scroll_indicator(self, direction: str, count: int) -> str:
419
+ """Create scroll indicator with gradient background.
420
+
421
+ Args:
422
+ direction: "up" or "down"
423
+ count: Number of items in that direction
424
+
425
+ Returns:
426
+ Formatted scroll indicator string with gradient.
427
+ """
428
+ arrow = ARROW_UP if direction == "up" else ARROW_DOWN
429
+ line_width = self._get_menu_width()
430
+
431
+ # Solid start portion with arrow and count
432
+ cyan_bg = make_bg_color(*AgnosterColors.CYAN_DARK)
433
+ text_light = make_fg_color(*AgnosterColors.TEXT_LIGHT)
434
+ start_text = f" {arrow} {count} "
435
+
436
+ # Gradient fade portion (remaining width)
437
+ fade_width = line_width - len(start_text)
438
+ fade_spaces = " " * fade_width
439
+
440
+ # Apply gradient from cyan to near-black
441
+ gradient_fade = apply_bg_gradient(
442
+ fade_spaces,
443
+ start_color=AgnosterColors.CYAN_DARK,
444
+ end_color=(15, 25, 35), # Dark blue-grey
445
+ )
446
+
447
+ line = (
448
+ f"{cyan_bg}{text_light} {arrow} {BOLD}{count}{RESET}"
449
+ f"{gradient_fade}"
450
+ )
451
+ return line
452
+
453
+ def _make_footer(self) -> List[str]:
454
+ """Create footer with keybind hints.
455
+
456
+ Returns:
457
+ List of formatted footer lines.
458
+ """
459
+ hint_text = "↑↓ navigate ⏎ select esc cancel"
460
+ return [f" {DIM} {hint_text}{RESET}"]
461
+
462
+ def _format_subcommand_item(self, item: Dict[str, Any], is_selected: bool) -> str:
463
+ """Format a single subcommand as a selectable menu item.
464
+
465
+ Args:
466
+ item: Subcommand item dict with subcommand_name, subcommand_args, subcommand_desc.
467
+ is_selected: Whether this item is currently selected.
468
+
469
+ Returns:
470
+ Formatted subcommand line.
471
+ """
472
+ name = item.get("subcommand_name", "")
473
+ args = item.get("subcommand_args", "")
474
+ desc = item.get("subcommand_desc", "")
475
+
476
+ # Format: " new <name> <cmd> Create session..."
477
+ cmd_part = f"{name}"
478
+ if args:
479
+ cmd_part += f" {args}"
480
+
481
+ # Pad command part to align descriptions
482
+ cmd_padded = cmd_part.ljust(20)
483
+
484
+ if is_selected:
485
+ # Selected subcommand - highlighted
486
+ cyan_bg = make_bg_color(*AgnosterColors.CYAN_DARK)
487
+ cyan_fg = make_fg_color(*AgnosterColors.CYAN)
488
+ lime_fg = make_fg_color(*AgnosterColors.LIME)
489
+ text_light = make_fg_color(*AgnosterColors.TEXT_LIGHT)
490
+ dark_bg = make_bg_color(*AgnosterColors.BG_MID)
491
+
492
+ line = f" {lime_fg}{GLOW}{RESET} {cyan_fg}{BOLD}{cmd_padded}{RESET} {text_light}{desc}{RESET}"
493
+ else:
494
+ # Unselected subcommand - dimmed
495
+ cyan_fg = make_fg_color(*AgnosterColors.CYAN)
496
+ line = f" {cyan_fg}{cmd_padded}{RESET} {DIM}{desc}{RESET}"
497
+
498
+ return line
499
+
500
+ def _format_category_header(self, category: str) -> str:
501
+ """Format a powerline-style category header.
502
+
503
+ Args:
504
+ category: Category identifier.
505
+
506
+ Returns:
507
+ Formatted category header string with powerline transitions.
508
+ """
509
+ config = CATEGORY_CONFIG.get(category, {"name": "???", "icon": "?", "full": category.title()})
510
+
511
+ # Build powerline: icon segment -> name segment -> fade out
512
+ lime_bg = make_bg_color(*AgnosterColors.LIME)
513
+ lime_fg = make_fg_color(*AgnosterColors.LIME)
514
+ dark_bg = make_bg_color(*AgnosterColors.BG_DARK)
515
+ dark_fg = make_fg_color(*AgnosterColors.TEXT_DARK)
516
+ light_fg = make_fg_color(*AgnosterColors.TEXT_LIGHT)
517
+
518
+ # Icon in lime -> powerline separator -> name in dark
519
+ line = (
520
+ f"{lime_bg}{dark_fg}{BOLD} {config['icon']} {RESET}"
521
+ f"{dark_bg}{lime_fg}{PL_RIGHT}{RESET}"
522
+ f"{dark_bg}{light_fg} {config['full']} {RESET}"
523
+ f"{lime_fg}{PL_RIGHT}{RESET}"
524
+ )
525
+ return line
526
+
527
+ def _sort_commands_by_category(self, commands: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
528
+ """Sort commands by category order for grouped display.
529
+
530
+ Args:
531
+ commands: List of command dictionaries.
532
+
533
+ Returns:
534
+ Sorted list of commands.
535
+ """
536
+ def get_category_order(cmd):
537
+ category = cmd.get("category", "custom")
538
+ # Handle CommandCategory enum
539
+ if hasattr(category, 'value'):
540
+ category = category.value
541
+ try:
542
+ return CATEGORY_ORDER.index(category)
543
+ except ValueError:
544
+ return len(CATEGORY_ORDER) # Unknown categories go last
545
+
546
+ return sorted(commands, key=get_category_order)
547
+
205
548
  def _group_commands_by_category(self) -> Dict[str, List[Dict[str, Any]]]:
206
549
  """Group commands by category for organized display.
207
550
 
@@ -244,31 +587,89 @@ class CommandMenuRenderer:
244
587
  }
245
588
  return category_names.get(category, category.title())
246
589
 
247
- def _format_command_line(self, cmd: Dict[str, Any]) -> str:
248
- """Format a single command line for display.
590
+ def _format_command_line(self, cmd: Dict[str, Any], category: str = "custom") -> str:
591
+ """Format a single command line with powerline style.
249
592
 
250
593
  Args:
251
594
  cmd: Command dictionary with display info.
595
+ category: Category for color theming.
252
596
 
253
597
  Returns:
254
598
  Formatted command line string.
255
599
  """
256
- # Selection indicator
257
- indicator = "❯ " if cmd.get("_is_selected", False) else " "
258
-
259
- # Command name with aliases
260
- name_part = f"/{cmd['name']}"
261
- if cmd.get("aliases"):
262
- aliases_str = ", ".join(cmd["aliases"])
263
- name_part += f" ({aliases_str})"
264
-
265
- # Description
600
+ is_selected = cmd.get("_is_selected", False)
601
+ name = cmd['name']
266
602
  description = cmd.get("description", "")
267
-
268
- # Format the complete line
269
- line = f"{indicator}{name_part:<30} {description}"
270
-
271
- return line
603
+ aliases = cmd.get("aliases", [])
604
+ line_width = self._get_menu_width()
605
+
606
+ if is_selected:
607
+ # SELECTED: Full powerline glow effect
608
+ # [glow] [cyan: /name] [dark: description padded] [aliases]
609
+ cyan_bg = make_bg_color(*AgnosterColors.CYAN)
610
+ cyan_fg = make_fg_color(*AgnosterColors.CYAN)
611
+ lime_bg = make_bg_color(*AgnosterColors.LIME)
612
+ lime_fg = make_fg_color(*AgnosterColors.LIME)
613
+ dark_bg = make_bg_color(*AgnosterColors.BG_MID)
614
+ dark_fg = make_fg_color(*AgnosterColors.BG_MID)
615
+ text_dark = make_fg_color(*AgnosterColors.TEXT_DARK)
616
+ text_light = make_fg_color(*AgnosterColors.TEXT_LIGHT)
617
+
618
+ # Calculate available space for description
619
+ # Layout: " ◆ " (4) + /name + " " (varies) + description + padding
620
+ name_part = f" /{name} "
621
+ prefix_len = 4 + len(name_part) + 2 # glow + name + separators
622
+
623
+ # Build alias hint if available
624
+ alias_hint = ""
625
+ alias_len = 0
626
+ if aliases:
627
+ alias_str = " ".join(f"/{a}" for a in aliases[:2]) # Max 2 aliases
628
+ alias_hint = f" {DIM}also: {alias_str}{RESET}"
629
+ alias_len = len(f" also: {alias_str}")
630
+
631
+ # Available for description + padding
632
+ desc_area = line_width - prefix_len - alias_len - 2
633
+ if len(description) > desc_area:
634
+ description = description[:desc_area-2] + ".."
635
+ desc_padded = description.ljust(desc_area)
636
+
637
+ line = (
638
+ f"{lime_bg}{text_dark}{BOLD} {GLOW} {RESET}"
639
+ f"{cyan_bg}{lime_fg}{PL_RIGHT}{RESET}"
640
+ f"{cyan_bg}{text_dark}{BOLD} /{name} {RESET}"
641
+ f"{dark_bg}{cyan_fg}{PL_RIGHT}{RESET}"
642
+ f"{dark_bg}{text_light} {desc_padded}{RESET}"
643
+ f"{dark_fg}{PL_RIGHT}{RESET}"
644
+ f"{alias_hint}"
645
+ )
646
+ return line
647
+ else:
648
+ # NOT SELECTED: Subtle powerline with full-width description
649
+ mid_bg = make_bg_color(*AgnosterColors.BG_DARK)
650
+ mid_fg = make_fg_color(*AgnosterColors.BG_DARK)
651
+ text_light = make_fg_color(*AgnosterColors.TEXT_LIGHT)
652
+ cyan_fg = make_fg_color(*AgnosterColors.CYAN)
653
+
654
+ # Dot leader between name and description
655
+ name_str = f"/{name}"
656
+ name_col_width = 14 # Fixed column for command name
657
+ padding_len = name_col_width - len(name_str)
658
+ dots = DOT * max(2, padding_len)
659
+
660
+ # Calculate description area
661
+ # Layout: " " (3) + name_col + " " + dots + " " + description
662
+ prefix_len = 3 + name_col_width + 4 # indent + name + powerline + spacing
663
+ desc_area = line_width - prefix_len - 2
664
+ if len(description) > desc_area:
665
+ description = description[:desc_area-2] + ".."
666
+
667
+ line = (
668
+ f" {mid_bg}{cyan_fg}{BOLD} {name_str} {RESET}"
669
+ f"{mid_fg}{PL_RIGHT}{RESET}"
670
+ f" {DIM}{dots} {description}{RESET}"
671
+ )
672
+ return line
272
673
 
273
674
  def _display_menu_overlay(self, menu_lines: List[str]) -> None:
274
675
  """Display menu as overlay on terminal.
@@ -315,5 +716,8 @@ class CommandMenuRenderer:
315
716
  "command_count": len(self.current_commands),
316
717
  "selected_index": self.selected_index,
317
718
  "filter_text": self.filter_text,
318
- "selected_command": self.get_selected_command()
719
+ "selected_command": self.get_selected_command(),
720
+ "scroll_offset": self.scroll_offset,
721
+ "max_visible_items": self.max_visible_items,
722
+ "visible_range": f"{self.scroll_offset}-{min(self.scroll_offset + self.max_visible_items, len(self.current_commands))}"
319
723
  }