kollabor 0.4.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. core/__init__.py +18 -0
  2. core/application.py +578 -0
  3. core/cli.py +193 -0
  4. core/commands/__init__.py +43 -0
  5. core/commands/executor.py +277 -0
  6. core/commands/menu_renderer.py +319 -0
  7. core/commands/parser.py +186 -0
  8. core/commands/registry.py +331 -0
  9. core/commands/system_commands.py +479 -0
  10. core/config/__init__.py +7 -0
  11. core/config/llm_task_config.py +110 -0
  12. core/config/loader.py +501 -0
  13. core/config/manager.py +112 -0
  14. core/config/plugin_config_manager.py +346 -0
  15. core/config/plugin_schema.py +424 -0
  16. core/config/service.py +399 -0
  17. core/effects/__init__.py +1 -0
  18. core/events/__init__.py +12 -0
  19. core/events/bus.py +129 -0
  20. core/events/executor.py +154 -0
  21. core/events/models.py +258 -0
  22. core/events/processor.py +176 -0
  23. core/events/registry.py +289 -0
  24. core/fullscreen/__init__.py +19 -0
  25. core/fullscreen/command_integration.py +290 -0
  26. core/fullscreen/components/__init__.py +12 -0
  27. core/fullscreen/components/animation.py +258 -0
  28. core/fullscreen/components/drawing.py +160 -0
  29. core/fullscreen/components/matrix_components.py +177 -0
  30. core/fullscreen/manager.py +302 -0
  31. core/fullscreen/plugin.py +204 -0
  32. core/fullscreen/renderer.py +282 -0
  33. core/fullscreen/session.py +324 -0
  34. core/io/__init__.py +52 -0
  35. core/io/buffer_manager.py +362 -0
  36. core/io/config_status_view.py +272 -0
  37. core/io/core_status_views.py +410 -0
  38. core/io/input_errors.py +313 -0
  39. core/io/input_handler.py +2655 -0
  40. core/io/input_mode_manager.py +402 -0
  41. core/io/key_parser.py +344 -0
  42. core/io/layout.py +587 -0
  43. core/io/message_coordinator.py +204 -0
  44. core/io/message_renderer.py +601 -0
  45. core/io/modal_interaction_handler.py +315 -0
  46. core/io/raw_input_processor.py +946 -0
  47. core/io/status_renderer.py +845 -0
  48. core/io/terminal_renderer.py +586 -0
  49. core/io/terminal_state.py +551 -0
  50. core/io/visual_effects.py +734 -0
  51. core/llm/__init__.py +26 -0
  52. core/llm/api_communication_service.py +863 -0
  53. core/llm/conversation_logger.py +473 -0
  54. core/llm/conversation_manager.py +414 -0
  55. core/llm/file_operations_executor.py +1401 -0
  56. core/llm/hook_system.py +402 -0
  57. core/llm/llm_service.py +1629 -0
  58. core/llm/mcp_integration.py +386 -0
  59. core/llm/message_display_service.py +450 -0
  60. core/llm/model_router.py +214 -0
  61. core/llm/plugin_sdk.py +396 -0
  62. core/llm/response_parser.py +848 -0
  63. core/llm/response_processor.py +364 -0
  64. core/llm/tool_executor.py +520 -0
  65. core/logging/__init__.py +19 -0
  66. core/logging/setup.py +208 -0
  67. core/models/__init__.py +5 -0
  68. core/models/base.py +23 -0
  69. core/plugins/__init__.py +13 -0
  70. core/plugins/collector.py +212 -0
  71. core/plugins/discovery.py +386 -0
  72. core/plugins/factory.py +263 -0
  73. core/plugins/registry.py +152 -0
  74. core/storage/__init__.py +5 -0
  75. core/storage/state_manager.py +84 -0
  76. core/ui/__init__.py +6 -0
  77. core/ui/config_merger.py +176 -0
  78. core/ui/config_widgets.py +369 -0
  79. core/ui/live_modal_renderer.py +276 -0
  80. core/ui/modal_actions.py +162 -0
  81. core/ui/modal_overlay_renderer.py +373 -0
  82. core/ui/modal_renderer.py +591 -0
  83. core/ui/modal_state_manager.py +443 -0
  84. core/ui/widget_integration.py +222 -0
  85. core/ui/widgets/__init__.py +27 -0
  86. core/ui/widgets/base_widget.py +136 -0
  87. core/ui/widgets/checkbox.py +85 -0
  88. core/ui/widgets/dropdown.py +140 -0
  89. core/ui/widgets/label.py +78 -0
  90. core/ui/widgets/slider.py +185 -0
  91. core/ui/widgets/text_input.py +224 -0
  92. core/utils/__init__.py +11 -0
  93. core/utils/config_utils.py +656 -0
  94. core/utils/dict_utils.py +212 -0
  95. core/utils/error_utils.py +275 -0
  96. core/utils/key_reader.py +171 -0
  97. core/utils/plugin_utils.py +267 -0
  98. core/utils/prompt_renderer.py +151 -0
  99. kollabor-0.4.9.dist-info/METADATA +298 -0
  100. kollabor-0.4.9.dist-info/RECORD +128 -0
  101. kollabor-0.4.9.dist-info/WHEEL +5 -0
  102. kollabor-0.4.9.dist-info/entry_points.txt +2 -0
  103. kollabor-0.4.9.dist-info/licenses/LICENSE +21 -0
  104. kollabor-0.4.9.dist-info/top_level.txt +4 -0
  105. kollabor_cli_main.py +20 -0
  106. plugins/__init__.py +1 -0
  107. plugins/enhanced_input/__init__.py +18 -0
  108. plugins/enhanced_input/box_renderer.py +103 -0
  109. plugins/enhanced_input/box_styles.py +142 -0
  110. plugins/enhanced_input/color_engine.py +165 -0
  111. plugins/enhanced_input/config.py +150 -0
  112. plugins/enhanced_input/cursor_manager.py +72 -0
  113. plugins/enhanced_input/geometry.py +81 -0
  114. plugins/enhanced_input/state.py +130 -0
  115. plugins/enhanced_input/text_processor.py +115 -0
  116. plugins/enhanced_input_plugin.py +385 -0
  117. plugins/fullscreen/__init__.py +9 -0
  118. plugins/fullscreen/example_plugin.py +327 -0
  119. plugins/fullscreen/matrix_plugin.py +132 -0
  120. plugins/hook_monitoring_plugin.py +1299 -0
  121. plugins/query_enhancer_plugin.py +350 -0
  122. plugins/save_conversation_plugin.py +502 -0
  123. plugins/system_commands_plugin.py +93 -0
  124. plugins/tmux_plugin.py +795 -0
  125. plugins/workflow_enforcement_plugin.py +629 -0
  126. system_prompt/default.md +1286 -0
  127. system_prompt/default_win.md +265 -0
  128. system_prompt/example_with_trender.md +47 -0
@@ -0,0 +1,845 @@
1
+ import re
2
+ import logging
3
+ import sys
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from typing import List, Dict, Any, Optional, Callable
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ # Platform check for keyboard shortcut display
11
+ IS_WINDOWS = sys.platform == "win32"
12
+
13
+
14
+ class StatusFormat(Enum):
15
+ """Status area formatting styles."""
16
+
17
+ COMPACT = "compact"
18
+ DETAILED = "detailed"
19
+ MINIMAL = "minimal"
20
+ BRACKETED = "bracketed"
21
+
22
+
23
+ @dataclass
24
+ class BlockConfig:
25
+ """Configuration for a single status block."""
26
+
27
+ width_fraction: float # 0.25, 0.33, 0.5, 0.67, 1.0
28
+ content_provider: Callable[[], List[str]] # Function that returns status content
29
+ title: str # Block title/label
30
+ priority: int = 0 # Block priority within view
31
+
32
+
33
+ @dataclass
34
+ class StatusViewConfig:
35
+ """Configuration for a complete status view."""
36
+
37
+ name: str # "Session Stats", "Performance", "My Plugin View"
38
+ plugin_source: str # Plugin that registered this view
39
+ priority: int # Display order priority
40
+ blocks: List[BlockConfig] # Block layout configuration
41
+
42
+
43
+ @dataclass
44
+ class StatusMetric:
45
+ """Represents a single status metric."""
46
+
47
+ key: str
48
+ value: Any
49
+ format_type: str = (
50
+ "default" # "number", "boolean", "time", "ratio", "percentage"
51
+ )
52
+ color_hint: Optional[str] = None
53
+ priority: int = 0
54
+
55
+ def format_value(self) -> str:
56
+ """Format the value based on its type."""
57
+ if self.format_type == "boolean":
58
+ return "Yes" if self.value else "No"
59
+ elif self.format_type == "time":
60
+ if isinstance(self.value, (int, float)):
61
+ return f"{self.value:.1f}s"
62
+ return str(self.value)
63
+ elif self.format_type == "ratio":
64
+ if isinstance(self.value, tuple) and len(self.value) == 2:
65
+ return f"{self.value[0]}/{self.value[1]}"
66
+ return str(self.value)
67
+ elif self.format_type == "percentage":
68
+ if isinstance(self.value, (int, float)):
69
+ return f"{self.value:.1f}%"
70
+ return str(self.value)
71
+ elif self.format_type == "number":
72
+ if isinstance(self.value, int) and self.value >= 1000:
73
+ # Add comma separators for large numbers
74
+ return f"{self.value:,}"
75
+ return str(self.value)
76
+ else:
77
+ return str(self.value)
78
+
79
+ def get_display_text(self) -> str:
80
+ """Get formatted display text for this metric."""
81
+ formatted_value = self.format_value()
82
+ return f"{self.key}: {formatted_value}"
83
+
84
+
85
+ class StatusAreaManager:
86
+ """Manages individual status areas and their content."""
87
+
88
+ def __init__(self, area_name: str):
89
+ """Initialize status area manager.
90
+
91
+ Args:
92
+ area_name: Name of the status area (A, B, C).
93
+ """
94
+ self.area_name = area_name
95
+ self.metrics: Dict[str, StatusMetric] = {}
96
+ self.custom_lines: List[str] = []
97
+ self.format_style = StatusFormat.BRACKETED
98
+
99
+ def add_metric(self, metric: StatusMetric) -> None:
100
+ """Add or update a status metric.
101
+
102
+ Args:
103
+ metric: StatusMetric to add.
104
+ """
105
+ self.metrics[metric.key] = metric
106
+
107
+ def update_metric(self, key: str, value: Any, **kwargs) -> None:
108
+ """Update an existing metric or create a new one.
109
+
110
+ Args:
111
+ key: Metric key.
112
+ value: New value.
113
+ **kwargs: Additional metric properties.
114
+ """
115
+ if key in self.metrics:
116
+ self.metrics[key].value = value
117
+ for attr, val in kwargs.items():
118
+ if hasattr(self.metrics[key], attr):
119
+ setattr(self.metrics[key], attr, val)
120
+ else:
121
+ self.add_metric(StatusMetric(key, value, **kwargs))
122
+
123
+ def remove_metric(self, key: str) -> None:
124
+ """Remove a metric from the status area.
125
+
126
+ Args:
127
+ key: Metric key to remove.
128
+ """
129
+ self.metrics.pop(key, None)
130
+
131
+ def add_custom_line(self, line: str) -> None:
132
+ """Add a custom formatted line to the status area.
133
+
134
+ Args:
135
+ line: Custom line text.
136
+ """
137
+ self.custom_lines.append(line)
138
+
139
+ def clear_custom_lines(self) -> None:
140
+ """Clear all custom lines."""
141
+ self.custom_lines.clear()
142
+
143
+ def get_formatted_lines(self, colorizer_func=None) -> List[str]:
144
+ """Get formatted lines for display.
145
+
146
+ Args:
147
+ colorizer_func: Optional function to apply colors to text.
148
+
149
+ Returns:
150
+ List of formatted status lines.
151
+ """
152
+ lines = []
153
+
154
+ # Add metric lines (sorted by priority)
155
+ sorted_metrics = sorted(
156
+ self.metrics.values(), key=lambda m: m.priority, reverse=True
157
+ )
158
+ for metric in sorted_metrics:
159
+ line = metric.get_display_text()
160
+
161
+ # Apply color hints if specified
162
+ if metric.color_hint and colorizer_func:
163
+ line = colorizer_func(line)
164
+ elif colorizer_func:
165
+ line = colorizer_func(line)
166
+
167
+ lines.append(line)
168
+
169
+ # Add custom lines
170
+ for line in self.custom_lines:
171
+ if colorizer_func:
172
+ line = colorizer_func(line)
173
+ lines.append(line)
174
+
175
+ return lines
176
+
177
+ def clear(self) -> None:
178
+ """Clear all metrics and custom lines."""
179
+ self.metrics.clear()
180
+ self.custom_lines.clear()
181
+
182
+
183
+ class StatusViewRegistry:
184
+ """Registry for plugin-configurable status views with navigation."""
185
+
186
+ def __init__(self, event_bus=None):
187
+ """Initialize status view registry.
188
+
189
+ Args:
190
+ event_bus: Event bus for firing status change events.
191
+ """
192
+ self.views: List[StatusViewConfig] = []
193
+ self.current_index = 0
194
+ self.event_bus = event_bus
195
+ logger.info("StatusViewRegistry initialized")
196
+
197
+ def register_status_view(
198
+ self, plugin_name: str, config: StatusViewConfig
199
+ ) -> None:
200
+ """Register a new status view from a plugin.
201
+
202
+ Args:
203
+ plugin_name: Name of the plugin registering the view.
204
+ config: StatusViewConfig for the new view.
205
+ """
206
+ # Add the view and sort by priority
207
+ self.views.append(config)
208
+ self.views.sort(key=lambda v: v.priority, reverse=True)
209
+
210
+ logger.info(
211
+ f"Registered status view '{config.name}' from plugin '{plugin_name}' with priority {config.priority}"
212
+ )
213
+
214
+ def cycle_next(self) -> Optional[StatusViewConfig]:
215
+ """Navigate to next status view.
216
+
217
+ Returns:
218
+ New current view config, or None if no views.
219
+ """
220
+ if not self.views:
221
+ return None
222
+
223
+ self.current_index = (self.current_index + 1) % len(self.views)
224
+ current_view = self.views[self.current_index]
225
+
226
+ # Fire status view changed event
227
+ if self.event_bus:
228
+ try:
229
+ # Import here to avoid circular imports
230
+ from ..events.models import EventType, Event
231
+
232
+ event = Event(
233
+ type=EventType.STATUS_VIEW_CHANGED,
234
+ data={"view_name": current_view.name, "direction": "next"},
235
+ source="status_view_registry",
236
+ )
237
+ self.event_bus.fire_event(event)
238
+ except Exception as e:
239
+ logger.warning(f"Failed to fire STATUS_VIEW_CHANGED event: {e}")
240
+
241
+ logger.debug(f"Cycled to next status view: '{current_view.name}'")
242
+ return current_view
243
+
244
+ def cycle_previous(self) -> Optional[StatusViewConfig]:
245
+ """Navigate to previous status view.
246
+
247
+ Returns:
248
+ New current view config, or None if no views.
249
+ """
250
+ if not self.views:
251
+ return None
252
+
253
+ self.current_index = (self.current_index - 1) % len(self.views)
254
+ current_view = self.views[self.current_index]
255
+
256
+ # Fire status view changed event
257
+ if self.event_bus:
258
+ try:
259
+ # Import here to avoid circular imports
260
+ from ..events.models import EventType, Event
261
+
262
+ event = Event(
263
+ type=EventType.STATUS_VIEW_CHANGED,
264
+ data={
265
+ "view_name": current_view.name,
266
+ "direction": "previous",
267
+ },
268
+ source="status_view_registry",
269
+ )
270
+ self.event_bus.fire_event(event)
271
+ except Exception as e:
272
+ logger.warning(f"Failed to fire STATUS_VIEW_CHANGED event: {e}")
273
+
274
+ logger.debug(f"Cycled to previous status view: '{current_view.name}'")
275
+ return current_view
276
+
277
+ def get_current_view(self) -> Optional[StatusViewConfig]:
278
+ """Get the currently active status view.
279
+
280
+ Returns:
281
+ Current view config, or None if no views registered.
282
+ """
283
+ if not self.views:
284
+ return None
285
+ return self.views[self.current_index]
286
+
287
+ def get_view_count(self) -> int:
288
+ """Get total number of registered views."""
289
+ return len(self.views)
290
+
291
+ def get_view_names(self) -> List[str]:
292
+ """Get names of all registered views."""
293
+ return [view.name for view in self.views]
294
+
295
+
296
+ class StatusRenderer:
297
+ """Main status rendering system coordinating multiple areas."""
298
+
299
+ def __init__(
300
+ self,
301
+ terminal_width: int = 80,
302
+ status_registry: Optional[StatusViewRegistry] = None,
303
+ ):
304
+ """Initialize status renderer.
305
+
306
+ Args:
307
+ terminal_width: Terminal width for layout calculations.
308
+ status_registry: Optional status view registry for block-based rendering.
309
+ """
310
+ self.terminal_width = terminal_width
311
+ self.status_registry = status_registry
312
+
313
+ # Create status area managers (legacy compatibility)
314
+ self.areas: Dict[str, StatusAreaManager] = {
315
+ "A": StatusAreaManager("A"),
316
+ "B": StatusAreaManager("B"),
317
+ "C": StatusAreaManager("C"),
318
+ }
319
+
320
+ # Rendering configuration
321
+ self.bracket_style = {
322
+ "open": "",
323
+ "close": "",
324
+ "color": "",
325
+ } # No brackets
326
+ self.spacing = (
327
+ 4 # Spacing between columns (increased for clarity without separator)
328
+ )
329
+ self.separator_style = "" # No separator - clean minimal aesthetic
330
+
331
+ def get_area(self, area_name: str) -> Optional[StatusAreaManager]:
332
+ """Get status area manager by name.
333
+
334
+ Args:
335
+ area_name: Area name (A, B, or C).
336
+
337
+ Returns:
338
+ StatusAreaManager instance or None.
339
+ """
340
+ return self.areas.get(area_name.upper())
341
+
342
+ def update_area_content(self, area_name: str, content: List[str]) -> None:
343
+ """Update area content with raw lines (backward compatibility).
344
+
345
+ Args:
346
+ area_name: Area name.
347
+ content: List of content lines.
348
+ """
349
+ area = self.get_area(area_name)
350
+ if area:
351
+ area.clear()
352
+ for line in content:
353
+ area.add_custom_line(line)
354
+
355
+ def set_terminal_width(self, width: int) -> None:
356
+ """Update terminal width for layout calculations.
357
+
358
+ Args:
359
+ width: New terminal width.
360
+ """
361
+ self.terminal_width = width
362
+
363
+ def render_horizontal_layout(self, colorizer_func=None) -> List[str]:
364
+ """Render status areas in horizontal (column) layout.
365
+
366
+ Args:
367
+ colorizer_func: Optional function to apply colors to text.
368
+
369
+ Returns:
370
+ List of formatted status lines.
371
+ """
372
+ # Use block-based rendering if registry is available and has views
373
+ if self.status_registry and self.status_registry.get_view_count() > 0:
374
+ return self._render_block_layout(colorizer_func)
375
+
376
+ # Fallback to legacy area-based rendering
377
+ return self._render_legacy_layout(colorizer_func)
378
+
379
+ def _render_legacy_layout(self, colorizer_func=None) -> List[str]:
380
+ """Render legacy area-based layout for backwards compatibility.
381
+
382
+ Args:
383
+ colorizer_func: Optional function to apply colors to text.
384
+
385
+ Returns:
386
+ List of formatted status lines.
387
+ """
388
+ # Get content for all areas
389
+ area_contents = {}
390
+ for name, area in self.areas.items():
391
+ content = area.get_formatted_lines(colorizer_func)
392
+ if content:
393
+ area_contents[name] = content
394
+
395
+ if not area_contents:
396
+ return []
397
+
398
+ # Use three-column layout for wide terminals
399
+ if self.terminal_width >= 80:
400
+ return self._render_three_column_layout(area_contents, colorizer_func)
401
+ else:
402
+ return self._render_vertical_layout(area_contents, colorizer_func)
403
+
404
+ def _render_three_column_layout(
405
+ self, area_contents: Dict[str, List[str]], colorizer_func=None
406
+ ) -> List[str]:
407
+ """Render three-column layout for wide terminals.
408
+
409
+ Args:
410
+ area_contents: Dictionary of area contents.
411
+ colorizer_func: Optional colorizer function.
412
+
413
+ Returns:
414
+ List of formatted lines.
415
+ """
416
+ lines = []
417
+
418
+ # Improved column width calculation
419
+ # Reserve space for brackets [text] and spacing between columns
420
+ brackets_overhead = 4 # 2 brackets + 2 padding spaces per column
421
+ total_spacing = (3 - 1) * self.spacing # spacing between 3 columns
422
+ available_width = self.terminal_width - total_spacing
423
+ column_width = max(15, (available_width - (3 * brackets_overhead)) // 3)
424
+
425
+ # Get content for areas A, B, C in order
426
+ area_names = ["A", "B", "C"]
427
+ area_data = []
428
+ for area_name in area_names:
429
+ content = area_contents.get(area_name, [])
430
+ area_data.append(content)
431
+
432
+ # Find maximum lines across all areas
433
+ max_lines = max(len(content) for content in area_data) if area_data else 0
434
+
435
+ # Create each row with three columns
436
+ for line_idx in range(max_lines):
437
+ columns = []
438
+
439
+ for content in area_data:
440
+ if line_idx < len(content):
441
+ text = content[line_idx]
442
+
443
+ # Truncate if too long for column (account for brackets)
444
+ visible_text = self._strip_ansi(text)
445
+ max_text_width = column_width - 2 # Reserve space for brackets
446
+
447
+ if len(visible_text) > max_text_width:
448
+ # Smart truncation - preserve important parts
449
+ if max_text_width > 3:
450
+ truncated = self._truncate_with_ansi(
451
+ text, max_text_width - 3
452
+ )
453
+ text = truncated + "..."
454
+ else:
455
+ text = "..."
456
+
457
+ # Apply bracket formatting
458
+ bracketed_text = self._apply_brackets(text)
459
+ columns.append(bracketed_text)
460
+ else:
461
+ columns.append("") # Empty column
462
+
463
+ # Join columns with improved spacing
464
+ formatted_line = self._join_columns_improved(
465
+ columns, column_width + brackets_overhead
466
+ )
467
+
468
+ # Only add line if it has content
469
+ if formatted_line.strip():
470
+ lines.append(formatted_line.rstrip())
471
+
472
+ return lines
473
+
474
+ def _render_vertical_layout(
475
+ self, area_contents: Dict[str, List[str]], colorizer_func=None
476
+ ) -> List[str]:
477
+ """Render vertical layout for narrow terminals.
478
+
479
+ Args:
480
+ area_contents: Dictionary of area contents.
481
+ colorizer_func: Optional colorizer function.
482
+
483
+ Returns:
484
+ List of formatted lines.
485
+ """
486
+ lines = []
487
+
488
+ # Render each area vertically
489
+ for area_name in ["A", "B", "C"]:
490
+ content = area_contents.get(area_name, [])
491
+ for line in content:
492
+ if line.strip():
493
+ bracketed_line = self._apply_brackets(line)
494
+ lines.append(bracketed_line)
495
+
496
+ return lines
497
+
498
+ def _apply_brackets(self, text: str) -> str:
499
+ """Apply bracket styling to text.
500
+
501
+ Args:
502
+ text: Text to apply brackets to.
503
+
504
+ Returns:
505
+ Text with brackets applied.
506
+ """
507
+ bracket_color = self.bracket_style["color"]
508
+ reset = "\033[0m"
509
+ open_bracket = self.bracket_style["open"]
510
+ close_bracket = self.bracket_style["close"]
511
+
512
+ return f"{bracket_color}{open_bracket}{reset}{text}{bracket_color}{close_bracket}{reset}"
513
+
514
+ def _join_columns(self, columns: List[str], column_width: int) -> str:
515
+ """Join columns with proper spacing and alignment (legacy method).
516
+
517
+ Args:
518
+ columns: List of column strings.
519
+ column_width: Width of each column.
520
+
521
+ Returns:
522
+ Joined line string.
523
+ """
524
+ return self._join_columns_improved(columns, column_width)
525
+
526
+ def _join_columns_improved(self, columns: List[str], column_width: int) -> str:
527
+ """Join columns with improved spacing and alignment.
528
+
529
+ Args:
530
+ columns: List of column strings.
531
+ column_width: Width of each column (including brackets).
532
+
533
+ Returns:
534
+ Joined line string.
535
+ """
536
+ formatted_line = ""
537
+
538
+ for i, col in enumerate(columns):
539
+ if col:
540
+ # Add the column content
541
+ formatted_line += col
542
+
543
+ # Calculate padding needed
544
+ visible_length = len(self._strip_ansi(col))
545
+ padding = max(0, column_width - visible_length)
546
+
547
+ # Add padding only if not the last column
548
+ if i < len(columns) - 1:
549
+ formatted_line += " " * padding
550
+ # Add inter-column spacing
551
+ formatted_line += " " * self.spacing
552
+ else:
553
+ # Empty column - add spacing if not last
554
+ if i < len(columns) - 1:
555
+ formatted_line += " " * column_width
556
+ formatted_line += " " * self.spacing
557
+
558
+ return formatted_line
559
+
560
+ def _strip_ansi(self, text: str) -> str:
561
+ """Remove ANSI escape codes from text.
562
+
563
+ Args:
564
+ text: Text with potential ANSI codes.
565
+
566
+ Returns:
567
+ Text with ANSI codes removed.
568
+ """
569
+ return re.sub(r"\033\[[0-9;]*m", "", text)
570
+
571
+ def _truncate_with_ansi(self, text: str, max_length: int) -> str:
572
+ """Truncate text while preserving ANSI codes.
573
+
574
+ Args:
575
+ text: Text to truncate.
576
+ max_length: Maximum visible length.
577
+
578
+ Returns:
579
+ Truncated text with ANSI codes preserved.
580
+ """
581
+ result = ""
582
+ visible_count = 0
583
+ i = 0
584
+
585
+ while i < len(text) and visible_count < max_length:
586
+ # Check for ANSI escape sequence
587
+ if (
588
+ text[i : i + 1] == "\033"
589
+ and i + 1 < len(text)
590
+ and text[i + 1] == "["
591
+ ):
592
+ # Find end of ANSI sequence
593
+ end = i + 2
594
+ while end < len(text) and text[end] not in "mhlABCDEFGHJKSTfimpsuI":
595
+ end += 1
596
+ if end < len(text):
597
+ end += 1
598
+
599
+ # Add the entire ANSI sequence
600
+ result += text[i:end]
601
+ i = end
602
+ else:
603
+ # Regular character
604
+ result += text[i]
605
+ visible_count += 1
606
+ i += 1
607
+
608
+ return result
609
+
610
+ def _render_block_layout(self, colorizer_func=None) -> List[str]:
611
+ """Render flexible block-based layout using StatusViewRegistry.
612
+
613
+ Args:
614
+ colorizer_func: Optional function to apply colors to text.
615
+
616
+ Returns:
617
+ List of formatted status lines.
618
+ """
619
+ if not self.status_registry:
620
+ return []
621
+
622
+ current_view = self.status_registry.get_current_view()
623
+ if not current_view:
624
+ return []
625
+
626
+ # Get content from all blocks in the current view
627
+ block_contents = []
628
+ for block in current_view.blocks:
629
+ try:
630
+ content = block.content_provider()
631
+ if content:
632
+ block_contents.append(
633
+ {
634
+ "width_fraction": block.width_fraction,
635
+ "title": block.title,
636
+ "content": content,
637
+ "priority": block.priority,
638
+ }
639
+ )
640
+ except Exception as e:
641
+ logger.warning(
642
+ f"Failed to get content from block '{block.title}': {e}"
643
+ )
644
+
645
+ if not block_contents:
646
+ return []
647
+
648
+ # Sort blocks by priority
649
+ block_contents.sort(key=lambda b: b["priority"], reverse=True)
650
+
651
+ # Calculate block layout
652
+ lines = self._calculate_and_render_blocks(block_contents, colorizer_func)
653
+
654
+ # Add cycling hint if multiple views are available
655
+ view_count = self.status_registry.get_view_count()
656
+ if view_count > 1:
657
+ current_index = (
658
+ self.status_registry.current_index + 1
659
+ ) # 1-indexed for display
660
+ # Use INFO_CYAN from Neon Minimal palette
661
+ # Use platform-appropriate modifier key name
662
+ mod_key = "Alt" if IS_WINDOWS else "Opt"
663
+ hint = f"\033[38;2;6;182;212m({mod_key}+, / {mod_key}+. to cycle • View {current_index}/{view_count}: {current_view.name})\033[0m"
664
+ lines.append(hint)
665
+
666
+ return lines
667
+
668
+ def _calculate_and_render_blocks(
669
+ self, block_contents: List[Dict], colorizer_func=None
670
+ ) -> List[str]:
671
+ """Calculate block layout and render status lines.
672
+
673
+ Args:
674
+ block_contents: List of block content dictionaries.
675
+ colorizer_func: Optional colorizer function.
676
+
677
+ Returns:
678
+ List of formatted status lines.
679
+ """
680
+ if not block_contents:
681
+ return []
682
+
683
+ # For now, implement horizontal layout similar to the legacy system
684
+ # This can be enhanced later for more complex layouts
685
+
686
+ # Calculate how many blocks can fit horizontally
687
+ total_width_needed = sum(block["width_fraction"] for block in block_contents)
688
+
689
+ if total_width_needed <= 1.0:
690
+ # All blocks fit in one row
691
+ return self._render_single_row_blocks(block_contents, colorizer_func)
692
+ else:
693
+ # Need multiple rows or vertical layout
694
+ return self._render_multi_row_blocks(block_contents, colorizer_func)
695
+
696
+ def _render_single_row_blocks(
697
+ self, block_contents: List[Dict], colorizer_func=None
698
+ ) -> List[str]:
699
+ """Render blocks in a single horizontal row.
700
+
701
+ Args:
702
+ block_contents: List of block content dictionaries.
703
+ colorizer_func: Optional colorizer function.
704
+
705
+ Returns:
706
+ List of formatted status lines.
707
+ """
708
+ lines = []
709
+
710
+ # Calculate actual column widths
711
+ total_spacing = (
712
+ (len(block_contents) - 1) * self.spacing
713
+ if len(block_contents) > 1
714
+ else 0
715
+ )
716
+ available_width = self.terminal_width - total_spacing
717
+
718
+ column_widths = []
719
+ for block in block_contents:
720
+ width = int(available_width * block["width_fraction"])
721
+ column_widths.append(max(10, width)) # Minimum width of 10
722
+
723
+ # Find maximum lines across all blocks
724
+ max_lines = (
725
+ max(len(block["content"]) for block in block_contents)
726
+ if block_contents
727
+ else 0
728
+ )
729
+
730
+ # Create each row
731
+ for line_idx in range(max_lines):
732
+ columns = []
733
+
734
+ for i, block in enumerate(block_contents):
735
+ if line_idx < len(block["content"]):
736
+ text = block["content"][line_idx]
737
+
738
+ # Apply colorizer
739
+ if colorizer_func:
740
+ text = colorizer_func(text)
741
+
742
+ # Truncate if too long
743
+ visible_text = self._strip_ansi(text)
744
+ max_width = column_widths[i]
745
+
746
+ if len(visible_text) > max_width:
747
+ if max_width > 3:
748
+ text = (
749
+ self._truncate_with_ansi(text, max_width - 3) + "..."
750
+ )
751
+ else:
752
+ text = "..."
753
+
754
+ columns.append(text)
755
+ else:
756
+ columns.append("") # Empty column
757
+
758
+ # Join columns with smart spacing (no separator needed)
759
+ formatted_line = ""
760
+ for i, col in enumerate(columns):
761
+ formatted_line += col
762
+
763
+ # Add spacing between columns (not after last)
764
+ if i < len(columns) - 1 and any(
765
+ columns[i + 1 :]
766
+ ): # Only add spacing if there are more non-empty columns
767
+ # Pad current column to its width
768
+ visible_length = len(self._strip_ansi(col))
769
+ padding = max(0, column_widths[i] - visible_length)
770
+ formatted_line += " " * padding
771
+ # Add clean inter-column spacing
772
+ formatted_line += " " * self.spacing
773
+
774
+ if formatted_line.strip():
775
+ lines.append(formatted_line.rstrip())
776
+
777
+ return lines
778
+
779
+ def _render_multi_row_blocks(
780
+ self, block_contents: List[Dict], colorizer_func=None
781
+ ) -> List[str]:
782
+ """Render blocks that don't fit in a single row.
783
+
784
+ Args:
785
+ block_contents: List of block content dictionaries.
786
+ colorizer_func: Optional colorizer function.
787
+
788
+ Returns:
789
+ List of formatted status lines.
790
+ """
791
+ lines = []
792
+
793
+ # For now, render each block on its own line(s)
794
+ # This is a simple fallback - can be enhanced later
795
+ for block in block_contents:
796
+ for content_line in block["content"]:
797
+ if colorizer_func:
798
+ content_line = colorizer_func(content_line)
799
+
800
+ # Truncate if too long
801
+ visible_text = self._strip_ansi(content_line)
802
+ if len(visible_text) > self.terminal_width - 3:
803
+ content_line = (
804
+ self._truncate_with_ansi(
805
+ content_line, self.terminal_width - 6
806
+ )
807
+ + "..."
808
+ )
809
+
810
+ lines.append(content_line)
811
+
812
+ return lines
813
+
814
+ def get_status_summary(self) -> Dict[str, Any]:
815
+ """Get summary of status rendering state.
816
+
817
+ Returns:
818
+ Dictionary with status information.
819
+ """
820
+ summary = {
821
+ "terminal_width": self.terminal_width,
822
+ "areas": {
823
+ name: {
824
+ "metrics_count": len(area.metrics),
825
+ "custom_lines_count": len(area.custom_lines),
826
+ "total_lines": len(area.get_formatted_lines()),
827
+ }
828
+ for name, area in self.areas.items()
829
+ },
830
+ "bracket_style": self.bracket_style,
831
+ "spacing": self.spacing,
832
+ "separator_style": self.separator_style,
833
+ }
834
+
835
+ # Add status registry information if available
836
+ if self.status_registry:
837
+ current_view = self.status_registry.get_current_view()
838
+ summary["status_registry"] = {
839
+ "view_count": self.status_registry.get_view_count(),
840
+ "view_names": self.status_registry.get_view_names(),
841
+ "current_view": current_view.name if current_view else None,
842
+ "current_blocks": (len(current_view.blocks) if current_view else 0),
843
+ }
844
+
845
+ return summary