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
core/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Core components for Kollabor CLI."""
2
+
3
+ # Import all subsystems for easy access
4
+ from .config import ConfigManager
5
+ from .events import EventBus, Event, EventType, Hook, HookStatus, HookPriority
6
+ from .io import InputHandler, TerminalRenderer
7
+ from .plugins import PluginRegistry
8
+ from .storage import StateManager
9
+ from .models import ConversationMessage
10
+
11
+ __all__ = [
12
+ 'ConfigManager',
13
+ 'EventBus', 'Event', 'EventType', 'Hook', 'HookStatus', 'HookPriority',
14
+ 'InputHandler', 'TerminalRenderer',
15
+ 'PluginRegistry',
16
+ 'StateManager',
17
+ 'ConversationMessage'
18
+ ]
core/application.py ADDED
@@ -0,0 +1,578 @@
1
+ """Main application orchestrator for Kollabor CLI."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import re
6
+ import sys
7
+ from pathlib import Path
8
+ from importlib.metadata import version as get_version, PackageNotFoundError
9
+
10
+ from .config import ConfigService
11
+
12
+ # Get version from package metadata (always authoritative)
13
+ try:
14
+ __version__ = get_version("kollabor")
15
+ except PackageNotFoundError:
16
+ __version__ = "0.4.7" # Fallback for development mode
17
+ from .events import EventBus
18
+ from .io import InputHandler, TerminalRenderer
19
+ from .io.visual_effects import VisualEffects
20
+ from .llm import LLMService, KollaborConversationLogger, LLMHookSystem, MCPIntegration, KollaborPluginSDK
21
+ from .logging import setup_from_config
22
+ from .plugins import PluginRegistry
23
+ from .storage import StateManager
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class TerminalLLMChat:
29
+ """Main Kollabor CLI application.
30
+
31
+ Orchestrates all components including rendering, input handling,
32
+ event processing, and plugin management.
33
+ """
34
+
35
+ def __init__(self) -> None:
36
+ """Initialize the chat application."""
37
+ # Get configuration directory using standard resolution
38
+ from .utils.config_utils import get_config_directory, ensure_config_directory, initialize_system_prompt
39
+
40
+ self.config_dir = ensure_config_directory()
41
+ logger.info(f"Using config directory: {self.config_dir}")
42
+
43
+ # Initialize system prompt (copies default.md to config directories)
44
+ initialize_system_prompt()
45
+
46
+ # Flag to indicate if we're in pipe mode (for plugins to check)
47
+ self.pipe_mode = False
48
+
49
+ # Initialize plugin registry and discover plugins
50
+ # Try package installation directory first (for pip install), then cwd (for development)
51
+ package_dir = Path(__file__).parent.parent # Go up from core/ to package root
52
+ plugins_dir = package_dir / "plugins"
53
+ if not plugins_dir.exists():
54
+ plugins_dir = Path.cwd() / "plugins" # Fallback for development mode
55
+ logger.info(f"Using development plugins directory: {plugins_dir}")
56
+ else:
57
+ logger.info(f"Using installed package plugins directory: {plugins_dir}")
58
+
59
+ self.plugin_registry = PluginRegistry(plugins_dir)
60
+ self.plugin_registry.load_all_plugins()
61
+
62
+ # Initialize configuration service with plugin registry
63
+ self.config = ConfigService(self.config_dir / "config.json", self.plugin_registry)
64
+
65
+ # Update config file with plugin configurations
66
+ self.config.update_from_plugins()
67
+
68
+ # Reconfigure logging now that config system is available
69
+ setup_from_config(self.config.config_manager.config)
70
+
71
+ # Initialize core components
72
+ self.state_manager = StateManager(str(self.config_dir / "state.db"))
73
+ self.event_bus = EventBus()
74
+
75
+ # Initialize status view registry for flexible status display
76
+ from .io.status_renderer import StatusViewRegistry
77
+ from .io.config_status_view import ConfigStatusView
78
+ self.status_registry = StatusViewRegistry(self.event_bus)
79
+
80
+ # Add config status view to registry
81
+ config_status_view = ConfigStatusView(self.config, self.event_bus)
82
+ config_view_config = config_status_view.get_status_view_config()
83
+ self.status_registry.register_status_view("core", config_view_config)
84
+
85
+ # Initialize renderer with status registry and config
86
+ self.renderer = TerminalRenderer(self.event_bus, self.config)
87
+ if hasattr(self.renderer, 'status_renderer'):
88
+ self.renderer.status_renderer.status_registry = self.status_registry
89
+
90
+ self.input_handler = InputHandler(self.event_bus, self.renderer, self.config)
91
+
92
+ # Give terminal renderer access to input handler for modal state checking
93
+ self.renderer.input_handler = self.input_handler
94
+
95
+ # Initialize visual effects system
96
+ self.visual_effects = VisualEffects()
97
+
98
+ # Initialize slash command system
99
+ logger.info("About to initialize slash command system")
100
+ self._initialize_slash_commands()
101
+
102
+ # Initialize fullscreen plugin commands
103
+ self._initialize_fullscreen_commands()
104
+ logger.info("Slash command system initialization completed")
105
+
106
+ # Initialize LLM core service components
107
+ conversations_dir = self.config_dir / "conversations"
108
+ conversations_dir.mkdir(parents=True, exist_ok=True)
109
+ self.conversation_logger = KollaborConversationLogger(conversations_dir)
110
+ self.llm_hook_system = LLMHookSystem(self.event_bus)
111
+ self.mcp_integration = MCPIntegration()
112
+ self.plugin_sdk = KollaborPluginSDK()
113
+ self.llm_service = LLMService(
114
+ config=self.config,
115
+ state_manager=self.state_manager,
116
+ event_bus=self.event_bus,
117
+ renderer=self.renderer
118
+ )
119
+
120
+ # Configure renderer with thinking effect and shimmer parameters
121
+ thinking_effect = self.config.get("terminal.thinking_effect", "shimmer")
122
+ shimmer_speed = self.config.get("terminal.shimmer_speed", 3)
123
+ shimmer_wave_width = self.config.get("terminal.shimmer_wave_width", 4)
124
+ thinking_limit = self.config.get("terminal.thinking_message_limit", 2)
125
+
126
+ self.renderer.set_thinking_effect(thinking_effect)
127
+ self.renderer.configure_shimmer(shimmer_speed, shimmer_wave_width)
128
+ self.renderer.configure_thinking_limit(thinking_limit)
129
+
130
+ # Dynamically instantiate all discovered plugins
131
+ self.plugin_instances = self.plugin_registry.instantiate_plugins(
132
+ self.state_manager, self.event_bus, self.renderer, self.config
133
+ )
134
+
135
+ # Task tracking for race condition prevention
136
+ self.running = False
137
+ self._startup_complete = False
138
+ self._background_tasks = []
139
+ self._task_lock = asyncio.Lock()
140
+
141
+ logger.info("Kollabor CLI initialized")
142
+
143
+ async def start(self) -> None:
144
+ """Start the chat application with guaranteed cleanup."""
145
+ # Display startup messages using config
146
+ self._display_startup_messages()
147
+
148
+ logger.info("Application starting")
149
+
150
+ render_task = None
151
+ input_task = None
152
+
153
+ try:
154
+ # Initialize LLM core service
155
+ await self._initialize_llm_core()
156
+
157
+ # Initialize all plugins dynamically
158
+ await self._initialize_plugins()
159
+
160
+ # Register default core status views
161
+ await self._register_core_status_views()
162
+
163
+ # Mark startup as complete
164
+ self._startup_complete = True
165
+ logger.info("Application startup complete")
166
+
167
+ # Start main loops with task tracking
168
+ self.running = True
169
+ render_task = self.create_background_task(
170
+ self._render_loop(), "render_loop"
171
+ )
172
+ input_task = self.create_background_task(
173
+ self.input_handler.start(), "input_handler"
174
+ )
175
+
176
+ # Wait for completion
177
+ await asyncio.gather(render_task, input_task)
178
+
179
+ except KeyboardInterrupt:
180
+ print("\r\n")
181
+ # print("\r\nInterrupted by user")
182
+ logger.info("Application interrupted by user")
183
+ except Exception as e:
184
+ logger.error(f"Application error during startup: {e}")
185
+ raise
186
+ finally:
187
+ # Guaranteed cleanup - always runs regardless of how we exit
188
+ logger.info("Executing guaranteed cleanup")
189
+ await self.cleanup()
190
+
191
+ async def start_pipe_mode(self, piped_input: str, timeout: int = 120) -> None:
192
+ """Start in pipe mode: process input and exit after response.
193
+
194
+ Args:
195
+ piped_input: Input text from stdin/pipe
196
+ timeout: Maximum time to wait for processing in seconds (default: 120)
197
+ """
198
+ # Set a flag to indicate we're in pipe mode (plugins can check this)
199
+ self.pipe_mode = True
200
+ self.renderer.pipe_mode = True # Also set on renderer for llm_service access
201
+ # Propagate pipe_mode to message renderer and conversation renderer
202
+ if hasattr(self.renderer, 'message_renderer'):
203
+ self.renderer.message_renderer.pipe_mode = True
204
+ if hasattr(self.renderer.message_renderer, 'conversation_renderer'):
205
+ self.renderer.message_renderer.conversation_renderer.pipe_mode = True
206
+
207
+ try:
208
+ # Initialize LLM core service
209
+ await self._initialize_llm_core()
210
+
211
+ # Initialize plugins (they should check self.pipe_mode if needed)
212
+ await self._initialize_plugins()
213
+
214
+ # Mark startup as complete
215
+ self._startup_complete = True
216
+ self.running = True
217
+ logger.info("Pipe mode initialized with plugins")
218
+
219
+ # Send input to LLM and wait for response
220
+ # The LLM service will handle the response display
221
+ await self.llm_service.process_user_input(piped_input)
222
+
223
+ # Wait for processing to start (max 10 seconds)
224
+ start_timeout = 10
225
+ start_wait = 0
226
+ while not self.llm_service.is_processing and start_wait < start_timeout:
227
+ await asyncio.sleep(0.1)
228
+ start_wait += 0.1
229
+
230
+ # Wait for processing to complete (including all tool calls and continuations)
231
+ max_wait = timeout
232
+ wait_time = 0
233
+ while self.llm_service.is_processing and not self.llm_service.cancel_processing and wait_time < max_wait:
234
+ await asyncio.sleep(0.1)
235
+ wait_time += 0.1
236
+
237
+ # Give a tiny bit of extra time for final display rendering
238
+ await asyncio.sleep(0.2)
239
+
240
+ logger.info("Pipe mode processing complete")
241
+
242
+ except KeyboardInterrupt:
243
+ logger.info("Pipe mode interrupted by user")
244
+ except Exception as e:
245
+ logger.error(f"Pipe mode error: {e}")
246
+ import traceback
247
+ traceback.print_exc()
248
+ raise
249
+ finally:
250
+ # Cleanup
251
+ self.running = False
252
+ # Keep pipe_mode=True during cleanup so cancellation messages can be suppressed
253
+ await self.cleanup()
254
+ # DON'T reset pipe_mode here - let main.py's finally block check it to avoid double cleanup
255
+
256
+ def _display_startup_messages(self) -> None:
257
+ """Display startup messages with plugin information."""
258
+ # Display Kollabor banner with version from package metadata
259
+ kollabor_banner = self.renderer.create_kollabor_banner(f"v{__version__}")
260
+ print(kollabor_banner)
261
+
262
+ # LLM Core status
263
+ #print(f"\033[2;35mLLM Core: \033[2;32mActive\033[0m")
264
+
265
+ # Plugin discovery section - clean and compact
266
+ discovered_plugins = self.plugin_registry.list_plugins()
267
+ if discovered_plugins:
268
+ # Simple plugin list
269
+ plugin_list = "//".join(discovered_plugins)
270
+ #print(f"\033[2;36mPlugins enabled: \033[2;37m{plugin_list}\033[0m")
271
+ print()
272
+ else:
273
+ #print("\033[2;31mNo plugins found\033[0m")
274
+ print()
275
+
276
+ # Ready message with gradient and bold Enter
277
+ ready_msg = "Ready! Type your message and press "
278
+ enter_text = "Enter"
279
+ end_text = "."
280
+
281
+ # Apply white to dim white gradient to the message
282
+ gradient_msg = self.visual_effects.apply_message_gradient(ready_msg, "dim_white")
283
+ bold_enter = f"\033[1m{enter_text}\033[0m" # Bold Enter
284
+ gradient_end = self.visual_effects.apply_message_gradient(end_text, "dim_white")
285
+
286
+ print(gradient_msg + bold_enter + gradient_end)
287
+ print()
288
+
289
+
290
+ async def _initialize_llm_core(self) -> None:
291
+ """Initialize LLM core service components."""
292
+ # Initialize LLM service
293
+ await self.llm_service.initialize()
294
+ logger.info("LLM core service initialized")
295
+
296
+ # Register LLM hooks
297
+ await self.llm_hook_system.register_hooks()
298
+ logger.info("LLM hook system registered")
299
+
300
+ # Initialize conversation logger
301
+ await self.conversation_logger.initialize()
302
+ logger.info("Conversation logger initialized")
303
+
304
+ # Discover MCP servers
305
+ mcp_servers = await self.mcp_integration.discover_mcp_servers()
306
+ if mcp_servers:
307
+ logger.info(f"Discovered {len(mcp_servers)} MCP servers")
308
+
309
+ # Register LLM service hooks for user input processing
310
+ await self.llm_service.register_hooks()
311
+
312
+ async def _initialize_plugins(self) -> None:
313
+ """Initialize all discovered plugins."""
314
+ # Deduplicate plugin instances by ID (same instance may be stored under multiple keys)
315
+ initialized_instances = set()
316
+
317
+ for plugin_name, plugin_instance in self.plugin_instances.items():
318
+ instance_id = id(plugin_instance)
319
+
320
+ # Skip if we've already initialized this instance
321
+ if instance_id in initialized_instances:
322
+ continue
323
+
324
+ initialized_instances.add(instance_id)
325
+
326
+ if hasattr(plugin_instance, 'initialize'):
327
+ # Pass command registry, input handler, llm_service, and renderer to plugins that might need it
328
+ init_kwargs = {
329
+ 'event_bus': self.event_bus,
330
+ 'config': self.config,
331
+ 'command_registry': getattr(self.input_handler, 'command_registry', None),
332
+ 'input_handler': self.input_handler,
333
+ 'renderer': self.renderer,
334
+ 'llm_service': self.llm_service
335
+ }
336
+
337
+ # Check if initialize method accepts keyword arguments
338
+ import inspect
339
+ sig = inspect.signature(plugin_instance.initialize)
340
+ if len(sig.parameters) > 0:
341
+ await plugin_instance.initialize(**init_kwargs)
342
+ else:
343
+ await plugin_instance.initialize()
344
+ logger.debug(f"Initialized plugin: {plugin_name}")
345
+
346
+ if hasattr(plugin_instance, 'register_hooks'):
347
+ await plugin_instance.register_hooks()
348
+ logger.debug(f"Registered hooks for plugin: {plugin_name}")
349
+
350
+ def _initialize_slash_commands(self) -> None:
351
+ """Initialize the slash command system with core commands."""
352
+ logger.info("Starting slash command system initialization...")
353
+ try:
354
+ from core.commands.system_commands import SystemCommandsPlugin
355
+ logger.info("SystemCommandsPlugin imported successfully")
356
+
357
+ # Create and register system commands
358
+ system_commands = SystemCommandsPlugin(
359
+ command_registry=self.input_handler.command_registry,
360
+ event_bus=self.event_bus,
361
+ config_manager=self.config
362
+ )
363
+ logger.info("SystemCommandsPlugin instance created")
364
+
365
+ # Register all system commands
366
+ system_commands.register_commands()
367
+ logger.info("System commands registration completed")
368
+
369
+ stats = self.input_handler.command_registry.get_registry_stats()
370
+ logger.info("Slash command system initialized with system commands")
371
+ logger.info(f"[INFO] {stats['total_commands']} commands registered")
372
+
373
+ except Exception as e:
374
+ logger.error(f"Failed to initialize slash command system: {e}")
375
+ import traceback
376
+ logger.error(f"[INFO] Traceback: {traceback.format_exc()}")
377
+
378
+ def _initialize_fullscreen_commands(self) -> None:
379
+ """Initialize dynamic fullscreen plugin commands."""
380
+ try:
381
+ from core.fullscreen.command_integration import FullScreenCommandIntegrator
382
+
383
+ # Create the integrator
384
+ self.fullscreen_integrator = FullScreenCommandIntegrator(
385
+ command_registry=self.input_handler.command_registry,
386
+ event_bus=self.event_bus
387
+ )
388
+
389
+ # Discover and register all fullscreen plugins
390
+ # Use same plugin directory resolution as main plugin registry
391
+ package_dir = Path(__file__).parent.parent
392
+ plugins_dir = package_dir / "plugins"
393
+ if not plugins_dir.exists():
394
+ plugins_dir = Path.cwd() / "plugins"
395
+ registered_count = self.fullscreen_integrator.discover_and_register_plugins(plugins_dir)
396
+
397
+ logger.info(f"Fullscreen plugin commands initialized: {registered_count} plugins registered")
398
+
399
+ except Exception as e:
400
+ logger.error(f"Failed to initialize fullscreen commands: {e}")
401
+ import traceback
402
+ logger.error(f"Fullscreen commands traceback: {traceback.format_exc()}")
403
+
404
+ async def _render_loop(self) -> None:
405
+ """Main rendering loop for status updates."""
406
+ logger.info("Render loop starting...")
407
+ while self.running:
408
+ try:
409
+ # Update status areas dynamically from plugins
410
+ status_areas = {"A": [], "B": [], "C": []}
411
+
412
+ # Core system status goes to area A
413
+ registry_stats = self.event_bus.get_registry_stats()
414
+ hook_count = registry_stats.get("total_hooks", 0)
415
+ status_areas["A"].append(f"Hooks: {hook_count}")
416
+
417
+ # LLM Core status
418
+ llm_status = self.llm_service.get_status_line()
419
+ if llm_status:
420
+ for area in ["A", "B", "C"]:
421
+ if area in llm_status:
422
+ status_areas[area].extend(llm_status[area])
423
+
424
+ # Collect status from all plugins (organized by area)
425
+ plugin_status_areas = self.plugin_registry.collect_status_lines(self.plugin_instances)
426
+
427
+ # Merge plugin status into our areas
428
+ for area in ["A", "B", "C"]:
429
+ status_areas[area].extend(plugin_status_areas[area])
430
+
431
+ # Handle spinner for processing status across all areas
432
+ for area in ["A", "B", "C"]:
433
+ for i, line in enumerate(status_areas[area]):
434
+ if line.startswith("Processing: Yes"):
435
+ spinner = self.renderer.thinking_animation.get_next_frame()
436
+ status_areas[area][i] = f"Processing: {spinner} Yes"
437
+ elif line.startswith("Processing: ") and "tokens" in line:
438
+ # Extract token count and add spinner
439
+ spinner = self.renderer.thinking_animation.get_next_frame()
440
+ token_part = line.replace("Processing: ", "")
441
+ status_areas[area][i] = f"Processing: {spinner} {token_part}"
442
+
443
+ # Update renderer with status areas
444
+ self.renderer.status_areas = status_areas
445
+
446
+ # Render active area
447
+ await self.renderer.render_active_area()
448
+
449
+ # Use configured FPS for render timing
450
+ render_fps = self.config.get("terminal.render_fps", 20)
451
+ await asyncio.sleep(1.0 / render_fps)
452
+
453
+ except Exception as e:
454
+ logger.error(f"Render loop error: {e}")
455
+ error_delay = self.config.get("terminal.render_error_delay", 0.1)
456
+ await asyncio.sleep(error_delay)
457
+
458
+ async def _register_core_status_views(self) -> None:
459
+ """Register default core status views."""
460
+ try:
461
+ from .io.core_status_views import CoreStatusViews
462
+ core_views = CoreStatusViews(self.llm_service, self.config)
463
+ core_views.register_all_views(self.status_registry)
464
+ except Exception as e:
465
+ logger.error(f"Failed to register core status views: {e}")
466
+
467
+ def create_background_task(self, coro, name: str = "unnamed"):
468
+ """Create and track a background task with automatic cleanup.
469
+
470
+ Args:
471
+ coro: Coroutine to run as background task
472
+ name: Human-readable name for the task
473
+
474
+ Returns:
475
+ The created asyncio.Task
476
+ """
477
+ task = asyncio.create_task(coro)
478
+ task.set_name(name)
479
+ self._background_tasks.append(task)
480
+ logger.debug(f"Created background task: {name}")
481
+
482
+ # Add callback to remove task from tracking when done
483
+ def remove_task(t):
484
+ try:
485
+ self._background_tasks.remove(t)
486
+ logger.debug(f"Background task completed: {name}")
487
+ except ValueError:
488
+ pass # Task already removed
489
+
490
+ task.add_done_callback(remove_task)
491
+ return task
492
+
493
+ async def cleanup(self) -> None:
494
+ """Clean up all resources and cancel background tasks.
495
+
496
+ This method is guaranteed to run on all exit paths via finally block.
497
+ Ensures no orphaned tasks or resources remain.
498
+ """
499
+ logger.info("Starting application cleanup...")
500
+
501
+ # Cancel all tracked background tasks
502
+ if self._background_tasks:
503
+ logger.info(f"Cancelling {len(self._background_tasks)} background tasks")
504
+ for task in self._background_tasks[:]: # Copy list to avoid modification during iteration
505
+ if not task.done():
506
+ task.cancel()
507
+
508
+ # Wait for all tasks to complete with timeout
509
+ if self._background_tasks:
510
+ try:
511
+ await asyncio.wait_for(
512
+ asyncio.gather(*self._background_tasks, return_exceptions=True),
513
+ timeout=5.0
514
+ )
515
+ except asyncio.TimeoutError:
516
+ logger.warning("Some tasks did not complete within timeout")
517
+ except Exception as e:
518
+ logger.error(f"Error during task cleanup: {e}")
519
+
520
+ # Clear task list
521
+ self._background_tasks.clear()
522
+
523
+ # Mark startup as incomplete
524
+ self._startup_complete = False
525
+ self.running = False
526
+
527
+ # Call full shutdown to cleanup other resources
528
+ await self.shutdown()
529
+
530
+ logger.info("Application cleanup complete")
531
+
532
+ def get_system_status(self):
533
+ """Get current system status for monitoring and debugging.
534
+
535
+ Returns:
536
+ Dictionary containing system status information
537
+ """
538
+ return {
539
+ "running": self.running,
540
+ "startup_complete": self._startup_complete,
541
+ "background_tasks": len(self._background_tasks),
542
+ "plugins_loaded": len(self.plugin_instances),
543
+ "task_names": [task.get_name() for task in self._background_tasks]
544
+ }
545
+
546
+ async def shutdown(self) -> None:
547
+ """Shutdown the application gracefully."""
548
+ logger.info("Application shutting down")
549
+ self.running = False
550
+
551
+ # Stop input handler
552
+ await self.input_handler.stop()
553
+
554
+ # Shutdown LLM core service
555
+ await self.llm_service.shutdown()
556
+ await self.conversation_logger.shutdown()
557
+ await self.mcp_integration.shutdown()
558
+ logger.info("LLM core service shutdown complete")
559
+
560
+ # Shutdown all plugins dynamically
561
+ for plugin_name, plugin_instance in self.plugin_instances.items():
562
+ if hasattr(plugin_instance, 'shutdown'):
563
+ try:
564
+ await plugin_instance.shutdown()
565
+ logger.debug(f"Shutdown plugin: {plugin_name}")
566
+ except Exception as e:
567
+ logger.warning(f"Error shutting down plugin {plugin_name}: {e}")
568
+
569
+ # Restore terminal
570
+ self.renderer.exit_raw_mode()
571
+ # Only show cursor if not in pipe mode
572
+ if not self.pipe_mode:
573
+ print("\033[?25h") # Show cursor
574
+ # print("Exiting...")
575
+
576
+ # Close state manager
577
+ self.state_manager.close()
578
+ logger.info("Application shutdown complete")