pomera-ai-commander 1.1.1 → 1.2.2

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 (213) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +105 -680
  3. package/bin/pomera-ai-commander.js +62 -62
  4. package/core/__init__.py +65 -65
  5. package/core/app_context.py +482 -482
  6. package/core/async_text_processor.py +421 -421
  7. package/core/backup_manager.py +655 -655
  8. package/core/backup_recovery_manager.py +1199 -1033
  9. package/core/content_hash_cache.py +508 -508
  10. package/core/context_menu.py +313 -313
  11. package/core/data_directory.py +549 -0
  12. package/core/data_validator.py +1066 -1066
  13. package/core/database_connection_manager.py +744 -744
  14. package/core/database_curl_settings_manager.py +608 -608
  15. package/core/database_promera_ai_settings_manager.py +446 -446
  16. package/core/database_schema.py +411 -411
  17. package/core/database_schema_manager.py +395 -395
  18. package/core/database_settings_manager.py +1507 -1507
  19. package/core/database_settings_manager_interface.py +456 -456
  20. package/core/dialog_manager.py +734 -734
  21. package/core/diff_utils.py +239 -0
  22. package/core/efficient_line_numbers.py +540 -510
  23. package/core/error_handler.py +746 -746
  24. package/core/error_service.py +431 -431
  25. package/core/event_consolidator.py +511 -511
  26. package/core/mcp/__init__.py +43 -43
  27. package/core/mcp/find_replace_diff.py +334 -0
  28. package/core/mcp/protocol.py +288 -288
  29. package/core/mcp/schema.py +251 -251
  30. package/core/mcp/server_stdio.py +299 -299
  31. package/core/mcp/tool_registry.py +2699 -2345
  32. package/core/memento.py +275 -0
  33. package/core/memory_efficient_text_widget.py +711 -711
  34. package/core/migration_manager.py +914 -914
  35. package/core/migration_test_suite.py +1085 -1085
  36. package/core/migration_validator.py +1143 -1143
  37. package/core/optimized_find_replace.py +714 -714
  38. package/core/optimized_pattern_engine.py +424 -424
  39. package/core/optimized_search_highlighter.py +552 -552
  40. package/core/performance_monitor.py +674 -674
  41. package/core/persistence_manager.py +712 -712
  42. package/core/progressive_stats_calculator.py +632 -632
  43. package/core/regex_pattern_cache.py +529 -529
  44. package/core/regex_pattern_library.py +350 -350
  45. package/core/search_operation_manager.py +434 -434
  46. package/core/settings_defaults_registry.py +1087 -1087
  47. package/core/settings_integrity_validator.py +1111 -1111
  48. package/core/settings_serializer.py +557 -557
  49. package/core/settings_validator.py +1823 -1823
  50. package/core/smart_stats_calculator.py +709 -709
  51. package/core/statistics_update_manager.py +619 -619
  52. package/core/stats_config_manager.py +858 -858
  53. package/core/streaming_text_handler.py +723 -723
  54. package/core/task_scheduler.py +596 -596
  55. package/core/update_pattern_library.py +168 -168
  56. package/core/visibility_monitor.py +596 -596
  57. package/core/widget_cache.py +498 -498
  58. package/mcp.json +51 -61
  59. package/migrate_data.py +127 -0
  60. package/package.json +64 -57
  61. package/pomera.py +7883 -7482
  62. package/pomera_mcp_server.py +183 -144
  63. package/requirements.txt +33 -0
  64. package/scripts/Dockerfile.alpine +43 -0
  65. package/scripts/Dockerfile.gui-test +54 -0
  66. package/scripts/Dockerfile.linux +43 -0
  67. package/scripts/Dockerfile.test-linux +80 -0
  68. package/scripts/Dockerfile.ubuntu +39 -0
  69. package/scripts/README.md +53 -0
  70. package/scripts/build-all.bat +113 -0
  71. package/scripts/build-docker.bat +53 -0
  72. package/scripts/build-docker.sh +55 -0
  73. package/scripts/build-optimized.bat +101 -0
  74. package/scripts/build.sh +78 -0
  75. package/scripts/docker-compose.test.yml +27 -0
  76. package/scripts/docker-compose.yml +32 -0
  77. package/scripts/postinstall.js +62 -0
  78. package/scripts/requirements-minimal.txt +33 -0
  79. package/scripts/test-linux-simple.bat +28 -0
  80. package/scripts/validate-release-workflow.py +450 -0
  81. package/tools/__init__.py +4 -4
  82. package/tools/ai_tools.py +2891 -2891
  83. package/tools/ascii_art_generator.py +352 -352
  84. package/tools/base64_tools.py +183 -183
  85. package/tools/base_tool.py +511 -511
  86. package/tools/case_tool.py +308 -308
  87. package/tools/column_tools.py +395 -395
  88. package/tools/cron_tool.py +884 -884
  89. package/tools/curl_history.py +600 -600
  90. package/tools/curl_processor.py +1207 -1207
  91. package/tools/curl_settings.py +502 -502
  92. package/tools/curl_tool.py +5467 -5467
  93. package/tools/diff_viewer.py +1817 -1072
  94. package/tools/email_extraction_tool.py +248 -248
  95. package/tools/email_header_analyzer.py +425 -425
  96. package/tools/extraction_tools.py +250 -250
  97. package/tools/find_replace.py +2289 -1750
  98. package/tools/folder_file_reporter.py +1463 -1463
  99. package/tools/folder_file_reporter_adapter.py +480 -480
  100. package/tools/generator_tools.py +1216 -1216
  101. package/tools/hash_generator.py +255 -255
  102. package/tools/html_tool.py +656 -656
  103. package/tools/jsonxml_tool.py +729 -729
  104. package/tools/line_tools.py +419 -419
  105. package/tools/markdown_tools.py +561 -561
  106. package/tools/mcp_widget.py +1417 -1417
  107. package/tools/notes_widget.py +978 -973
  108. package/tools/number_base_converter.py +372 -372
  109. package/tools/regex_extractor.py +571 -571
  110. package/tools/slug_generator.py +310 -310
  111. package/tools/sorter_tools.py +458 -458
  112. package/tools/string_escape_tool.py +392 -392
  113. package/tools/text_statistics_tool.py +365 -365
  114. package/tools/text_wrapper.py +430 -430
  115. package/tools/timestamp_converter.py +421 -421
  116. package/tools/tool_loader.py +710 -710
  117. package/tools/translator_tools.py +522 -522
  118. package/tools/url_link_extractor.py +261 -261
  119. package/tools/url_parser.py +204 -204
  120. package/tools/whitespace_tools.py +355 -355
  121. package/tools/word_frequency_counter.py +146 -146
  122. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  123. package/core/__pycache__/app_context.cpython-313.pyc +0 -0
  124. package/core/__pycache__/async_text_processor.cpython-313.pyc +0 -0
  125. package/core/__pycache__/backup_manager.cpython-313.pyc +0 -0
  126. package/core/__pycache__/backup_recovery_manager.cpython-313.pyc +0 -0
  127. package/core/__pycache__/content_hash_cache.cpython-313.pyc +0 -0
  128. package/core/__pycache__/context_menu.cpython-313.pyc +0 -0
  129. package/core/__pycache__/data_validator.cpython-313.pyc +0 -0
  130. package/core/__pycache__/database_connection_manager.cpython-313.pyc +0 -0
  131. package/core/__pycache__/database_curl_settings_manager.cpython-313.pyc +0 -0
  132. package/core/__pycache__/database_promera_ai_settings_manager.cpython-313.pyc +0 -0
  133. package/core/__pycache__/database_schema.cpython-313.pyc +0 -0
  134. package/core/__pycache__/database_schema_manager.cpython-313.pyc +0 -0
  135. package/core/__pycache__/database_settings_manager.cpython-313.pyc +0 -0
  136. package/core/__pycache__/database_settings_manager_interface.cpython-313.pyc +0 -0
  137. package/core/__pycache__/dialog_manager.cpython-313.pyc +0 -0
  138. package/core/__pycache__/efficient_line_numbers.cpython-313.pyc +0 -0
  139. package/core/__pycache__/error_handler.cpython-313.pyc +0 -0
  140. package/core/__pycache__/error_service.cpython-313.pyc +0 -0
  141. package/core/__pycache__/event_consolidator.cpython-313.pyc +0 -0
  142. package/core/__pycache__/memory_efficient_text_widget.cpython-313.pyc +0 -0
  143. package/core/__pycache__/migration_manager.cpython-313.pyc +0 -0
  144. package/core/__pycache__/migration_test_suite.cpython-313.pyc +0 -0
  145. package/core/__pycache__/migration_validator.cpython-313.pyc +0 -0
  146. package/core/__pycache__/optimized_find_replace.cpython-313.pyc +0 -0
  147. package/core/__pycache__/optimized_pattern_engine.cpython-313.pyc +0 -0
  148. package/core/__pycache__/optimized_search_highlighter.cpython-313.pyc +0 -0
  149. package/core/__pycache__/performance_monitor.cpython-313.pyc +0 -0
  150. package/core/__pycache__/persistence_manager.cpython-313.pyc +0 -0
  151. package/core/__pycache__/progressive_stats_calculator.cpython-313.pyc +0 -0
  152. package/core/__pycache__/regex_pattern_cache.cpython-313.pyc +0 -0
  153. package/core/__pycache__/regex_pattern_library.cpython-313.pyc +0 -0
  154. package/core/__pycache__/search_operation_manager.cpython-313.pyc +0 -0
  155. package/core/__pycache__/settings_defaults_registry.cpython-313.pyc +0 -0
  156. package/core/__pycache__/settings_integrity_validator.cpython-313.pyc +0 -0
  157. package/core/__pycache__/settings_serializer.cpython-313.pyc +0 -0
  158. package/core/__pycache__/settings_validator.cpython-313.pyc +0 -0
  159. package/core/__pycache__/smart_stats_calculator.cpython-313.pyc +0 -0
  160. package/core/__pycache__/statistics_update_manager.cpython-313.pyc +0 -0
  161. package/core/__pycache__/stats_config_manager.cpython-313.pyc +0 -0
  162. package/core/__pycache__/streaming_text_handler.cpython-313.pyc +0 -0
  163. package/core/__pycache__/task_scheduler.cpython-313.pyc +0 -0
  164. package/core/__pycache__/visibility_monitor.cpython-313.pyc +0 -0
  165. package/core/__pycache__/widget_cache.cpython-313.pyc +0 -0
  166. package/core/mcp/__pycache__/__init__.cpython-313.pyc +0 -0
  167. package/core/mcp/__pycache__/protocol.cpython-313.pyc +0 -0
  168. package/core/mcp/__pycache__/schema.cpython-313.pyc +0 -0
  169. package/core/mcp/__pycache__/server_stdio.cpython-313.pyc +0 -0
  170. package/core/mcp/__pycache__/tool_registry.cpython-313.pyc +0 -0
  171. package/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  172. package/tools/__pycache__/ai_tools.cpython-313.pyc +0 -0
  173. package/tools/__pycache__/ascii_art_generator.cpython-313.pyc +0 -0
  174. package/tools/__pycache__/base64_tools.cpython-313.pyc +0 -0
  175. package/tools/__pycache__/base_tool.cpython-313.pyc +0 -0
  176. package/tools/__pycache__/case_tool.cpython-313.pyc +0 -0
  177. package/tools/__pycache__/column_tools.cpython-313.pyc +0 -0
  178. package/tools/__pycache__/cron_tool.cpython-313.pyc +0 -0
  179. package/tools/__pycache__/curl_history.cpython-313.pyc +0 -0
  180. package/tools/__pycache__/curl_processor.cpython-313.pyc +0 -0
  181. package/tools/__pycache__/curl_settings.cpython-313.pyc +0 -0
  182. package/tools/__pycache__/curl_tool.cpython-313.pyc +0 -0
  183. package/tools/__pycache__/diff_viewer.cpython-313.pyc +0 -0
  184. package/tools/__pycache__/email_extraction_tool.cpython-313.pyc +0 -0
  185. package/tools/__pycache__/email_header_analyzer.cpython-313.pyc +0 -0
  186. package/tools/__pycache__/extraction_tools.cpython-313.pyc +0 -0
  187. package/tools/__pycache__/find_replace.cpython-313.pyc +0 -0
  188. package/tools/__pycache__/folder_file_reporter.cpython-313.pyc +0 -0
  189. package/tools/__pycache__/folder_file_reporter_adapter.cpython-313.pyc +0 -0
  190. package/tools/__pycache__/generator_tools.cpython-313.pyc +0 -0
  191. package/tools/__pycache__/hash_generator.cpython-313.pyc +0 -0
  192. package/tools/__pycache__/html_tool.cpython-313.pyc +0 -0
  193. package/tools/__pycache__/huggingface_helper.cpython-313.pyc +0 -0
  194. package/tools/__pycache__/jsonxml_tool.cpython-313.pyc +0 -0
  195. package/tools/__pycache__/line_tools.cpython-313.pyc +0 -0
  196. package/tools/__pycache__/list_comparator.cpython-313.pyc +0 -0
  197. package/tools/__pycache__/markdown_tools.cpython-313.pyc +0 -0
  198. package/tools/__pycache__/mcp_widget.cpython-313.pyc +0 -0
  199. package/tools/__pycache__/notes_widget.cpython-313.pyc +0 -0
  200. package/tools/__pycache__/number_base_converter.cpython-313.pyc +0 -0
  201. package/tools/__pycache__/regex_extractor.cpython-313.pyc +0 -0
  202. package/tools/__pycache__/slug_generator.cpython-313.pyc +0 -0
  203. package/tools/__pycache__/sorter_tools.cpython-313.pyc +0 -0
  204. package/tools/__pycache__/string_escape_tool.cpython-313.pyc +0 -0
  205. package/tools/__pycache__/text_statistics_tool.cpython-313.pyc +0 -0
  206. package/tools/__pycache__/text_wrapper.cpython-313.pyc +0 -0
  207. package/tools/__pycache__/timestamp_converter.cpython-313.pyc +0 -0
  208. package/tools/__pycache__/tool_loader.cpython-313.pyc +0 -0
  209. package/tools/__pycache__/translator_tools.cpython-313.pyc +0 -0
  210. package/tools/__pycache__/url_link_extractor.cpython-313.pyc +0 -0
  211. package/tools/__pycache__/url_parser.cpython-313.pyc +0 -0
  212. package/tools/__pycache__/whitespace_tools.cpython-313.pyc +0 -0
  213. package/tools/__pycache__/word_frequency_counter.cpython-313.pyc +0 -0
@@ -1,512 +1,512 @@
1
- """
2
- Event Consolidation System for Pomera AI Commander.
3
-
4
- This module provides intelligent event consolidation to replace multiple event handlers
5
- with a single consolidated handler, implementing adaptive debouncing and event deduplication
6
- to optimize statistics calculation performance.
7
-
8
- Requirements addressed:
9
- - 1.1: Consolidate multiple event triggers into single statistics update
10
- - 1.2: Use intelligent debouncing to prevent excessive calculations
11
- - 1.3: Increase debounce delays automatically for large content (>10,000 characters)
12
- - 1.4: Prevent duplicate statistics calculations
13
- """
14
-
15
- import time
16
- import threading
17
- import tkinter as tk
18
- from typing import Dict, Optional, Callable, Any, Set
19
- from dataclasses import dataclass, field
20
- from enum import Enum
21
- import weakref
22
-
23
-
24
- class EventType(Enum):
25
- """Types of text widget events that trigger statistics updates."""
26
- MODIFIED = "<<Modified>>"
27
- KEY_RELEASE = "<KeyRelease>"
28
- BUTTON_CLICK = "<Button-1>"
29
- FOCUS_IN = "<FocusIn>"
30
- FOCUS_OUT = "<FocusOut>"
31
-
32
-
33
- class DebounceStrategy(Enum):
34
- """Debouncing strategies for different content sizes and user behaviors."""
35
- IMMEDIATE = "immediate" # No debouncing for very small changes
36
- FAST = "fast" # 50ms for small content
37
- NORMAL = "normal" # 300ms for medium content
38
- SLOW = "slow" # 500ms for large content
39
- ADAPTIVE = "adaptive" # Automatically adjust based on content size
40
-
41
-
42
- @dataclass
43
- class EventInfo:
44
- """Information about a text widget event."""
45
- widget_id: str
46
- event_type: EventType
47
- timestamp: float
48
- content_size: int = 0
49
- content_hash: str = ""
50
-
51
- def __post_init__(self):
52
- if not self.timestamp:
53
- self.timestamp = time.time()
54
-
55
-
56
- @dataclass
57
- class DebounceConfig:
58
- """Configuration for debouncing behavior."""
59
- strategy: DebounceStrategy = DebounceStrategy.ADAPTIVE
60
- immediate_threshold: int = 100 # Characters below which updates are immediate
61
- fast_delay_ms: int = 50 # Delay for small content
62
- normal_delay_ms: int = 300 # Delay for medium content
63
- slow_delay_ms: int = 500 # Delay for large content
64
- large_content_threshold: int = 10000 # Characters above which content is considered large
65
- very_large_threshold: int = 100000 # Characters above which extra delays apply
66
- max_delay_ms: int = 1000 # Maximum debounce delay
67
-
68
- def get_delay_for_content_size(self, content_size: int) -> int:
69
- """Get appropriate debounce delay based on content size."""
70
- if self.strategy == DebounceStrategy.IMMEDIATE:
71
- return 0
72
- elif self.strategy == DebounceStrategy.FAST:
73
- return self.fast_delay_ms
74
- elif self.strategy == DebounceStrategy.NORMAL:
75
- return self.normal_delay_ms
76
- elif self.strategy == DebounceStrategy.SLOW:
77
- return self.slow_delay_ms
78
- elif self.strategy == DebounceStrategy.ADAPTIVE:
79
- if content_size < self.immediate_threshold:
80
- return 0
81
- elif content_size < 1000:
82
- return self.fast_delay_ms
83
- elif content_size < self.large_content_threshold:
84
- return self.normal_delay_ms
85
- elif content_size < self.very_large_threshold:
86
- return self.slow_delay_ms
87
- else:
88
- # Extra delay for very large content
89
- return min(self.max_delay_ms, self.slow_delay_ms + 200)
90
-
91
- return self.normal_delay_ms
92
-
93
-
94
- @dataclass
95
- class PendingUpdate:
96
- """Information about a pending statistics update."""
97
- widget_id: str
98
- event_info: EventInfo
99
- callback: Callable
100
- after_id: Optional[str] = None
101
- scheduled_time: float = field(default_factory=time.time)
102
-
103
- @property
104
- def is_expired(self) -> bool:
105
- """Check if this update has been waiting too long."""
106
- return (time.time() - self.scheduled_time) > 2.0 # 2 second timeout
107
-
108
-
109
- class EventConsolidator:
110
- """
111
- Consolidates multiple text widget events into single statistics updates.
112
-
113
- This class replaces multiple event handlers (<<Modified>>, <KeyRelease>, <Button-1>)
114
- with a single consolidated handler that implements intelligent debouncing and
115
- event deduplication to optimize performance.
116
- """
117
-
118
- def __init__(self, debounce_config: Optional[DebounceConfig] = None):
119
- """
120
- Initialize the event consolidator.
121
-
122
- Args:
123
- debounce_config: Configuration for debouncing behavior
124
- """
125
- self.debounce_config = debounce_config or DebounceConfig()
126
-
127
- # Widget registry - use weak references to avoid memory leaks
128
- self.registered_widgets: Dict[str, weakref.ref] = {}
129
- self.widget_callbacks: Dict[str, Callable] = {}
130
-
131
- # Event tracking for deduplication
132
- self.recent_events: Dict[str, EventInfo] = {}
133
- self.event_lock = threading.RLock()
134
-
135
- # Pending updates tracking
136
- self.pending_updates: Dict[str, PendingUpdate] = {}
137
-
138
- # Statistics for monitoring
139
- self.stats = {
140
- 'events_received': 0,
141
- 'events_deduplicated': 0,
142
- 'updates_triggered': 0,
143
- 'average_debounce_delay': 0.0
144
- }
145
-
146
- # Tkinter root reference for after() calls
147
- self._tk_root: Optional[tk.Tk] = None
148
-
149
- def set_tk_root(self, root: tk.Tk):
150
- """Set the Tkinter root for scheduling callbacks."""
151
- self._tk_root = root
152
-
153
- def register_text_widget(self, widget_id: str, widget: tk.Text,
154
- callback: Callable[[str], None]) -> None:
155
- """
156
- Register a text widget for consolidated event handling.
157
-
158
- Args:
159
- widget_id: Unique identifier for the widget
160
- widget: The text widget to monitor
161
- callback: Function to call when statistics should be updated
162
- """
163
- # Store weak reference to widget to avoid memory leaks
164
- self.registered_widgets[widget_id] = weakref.ref(widget)
165
- self.widget_callbacks[widget_id] = callback
166
-
167
- # Bind single consolidated event handler
168
- self._bind_consolidated_events(widget, widget_id)
169
-
170
- def unregister_text_widget(self, widget_id: str) -> None:
171
- """
172
- Unregister a text widget and cancel any pending updates.
173
-
174
- Args:
175
- widget_id: Identifier of the widget to unregister
176
- """
177
- # Cancel pending updates
178
- self.cancel_pending_updates(widget_id)
179
-
180
- # Remove from registries
181
- self.registered_widgets.pop(widget_id, None)
182
- self.widget_callbacks.pop(widget_id, None)
183
- self.recent_events.pop(widget_id, None)
184
-
185
- def _bind_consolidated_events(self, widget: tk.Text, widget_id: str) -> None:
186
- """
187
- Bind consolidated event handler to replace multiple event bindings.
188
-
189
- This replaces the multiple event bindings (<<Modified>>, <KeyRelease>, <Button-1>)
190
- with a single handler per widget as required.
191
- """
192
- # Create consolidated event handler
193
- def consolidated_handler(event=None):
194
- event_type = self._determine_event_type(event)
195
- self.handle_text_event(widget_id, event_type, event)
196
- return None # Don't break event propagation
197
-
198
- # Bind to all relevant events with single handler
199
- widget.bind("<<Modified>>", consolidated_handler, add=True)
200
- widget.bind("<KeyRelease>", consolidated_handler, add=True)
201
- widget.bind("<Button-1>", consolidated_handler, add=True)
202
-
203
- # Optional: Also handle focus events for better user experience
204
- widget.bind("<FocusIn>", consolidated_handler, add=True)
205
- widget.bind("<FocusOut>", consolidated_handler, add=True)
206
-
207
- def _determine_event_type(self, event) -> EventType:
208
- """Determine the type of event from the event object."""
209
- if not event:
210
- return EventType.MODIFIED
211
-
212
- event_str = str(event.type) if hasattr(event, 'type') else str(event)
213
-
214
- if "KeyRelease" in event_str or hasattr(event, 'keysym'):
215
- return EventType.KEY_RELEASE
216
- elif "Button" in event_str and hasattr(event, 'num'):
217
- return EventType.BUTTON_CLICK
218
- elif "FocusIn" in event_str:
219
- return EventType.FOCUS_IN
220
- elif "FocusOut" in event_str:
221
- return EventType.FOCUS_OUT
222
- else:
223
- return EventType.MODIFIED
224
-
225
- def handle_text_event(self, widget_id: str, event_type: EventType,
226
- event=None) -> None:
227
- """
228
- Handle consolidated text widget events with intelligent deduplication.
229
-
230
- Args:
231
- widget_id: Identifier of the widget that triggered the event
232
- event_type: Type of event that occurred
233
- event: Original event object (optional)
234
- """
235
- with self.event_lock:
236
- self.stats['events_received'] += 1
237
-
238
- # Get widget reference
239
- widget_ref = self.registered_widgets.get(widget_id)
240
- if not widget_ref:
241
- return
242
-
243
- widget = widget_ref()
244
- if not widget:
245
- # Widget was garbage collected, clean up
246
- self.unregister_text_widget(widget_id)
247
- return
248
-
249
- # Get current content for analysis
250
- try:
251
- content = widget.get("1.0", tk.END)
252
- content_size = len(content.encode('utf-8'))
253
- content_hash = self._generate_content_hash(content)
254
- except tk.TclError:
255
- # Widget might be destroyed
256
- return
257
-
258
- # Create event info
259
- event_info = EventInfo(
260
- widget_id=widget_id,
261
- event_type=event_type,
262
- timestamp=time.time(),
263
- content_size=content_size,
264
- content_hash=content_hash
265
- )
266
-
267
- # Check for duplicate events
268
- if self._is_duplicate_event(widget_id, event_info):
269
- self.stats['events_deduplicated'] += 1
270
- return
271
-
272
- # Store recent event for deduplication
273
- self.recent_events[widget_id] = event_info
274
-
275
- # Schedule update with appropriate debouncing
276
- self._schedule_update(widget_id, event_info)
277
-
278
- def _is_duplicate_event(self, widget_id: str, event_info: EventInfo) -> bool:
279
- """
280
- Check if this event is a duplicate that should be ignored.
281
-
282
- Args:
283
- widget_id: Widget identifier
284
- event_info: Information about the current event
285
-
286
- Returns:
287
- True if this is a duplicate event that should be ignored
288
- """
289
- recent_event = self.recent_events.get(widget_id)
290
- if not recent_event:
291
- return False
292
-
293
- # Check if content hasn't changed
294
- if recent_event.content_hash == event_info.content_hash:
295
- # Same content - check if enough time has passed
296
- time_diff = event_info.timestamp - recent_event.timestamp
297
- if time_diff < 0.1: # Less than 100ms
298
- return True
299
-
300
- # Check for rapid successive events of the same type
301
- if (recent_event.event_type == event_info.event_type and
302
- event_info.timestamp - recent_event.timestamp < 0.05): # Less than 50ms
303
- return True
304
-
305
- return False
306
-
307
- def _schedule_update(self, widget_id: str, event_info: EventInfo) -> None:
308
- """
309
- Schedule a statistics update with appropriate debouncing.
310
-
311
- Args:
312
- widget_id: Widget identifier
313
- event_info: Information about the event
314
- """
315
- # Cancel any existing pending update for this widget
316
- self.cancel_pending_updates(widget_id)
317
-
318
- # Get callback
319
- callback = self.widget_callbacks.get(widget_id)
320
- if not callback:
321
- return
322
-
323
- # Determine debounce delay
324
- delay_ms = self.debounce_config.get_delay_for_content_size(event_info.content_size)
325
-
326
- # Update statistics
327
- self.stats['average_debounce_delay'] = (
328
- (self.stats['average_debounce_delay'] * self.stats['updates_triggered'] + delay_ms) /
329
- (self.stats['updates_triggered'] + 1)
330
- )
331
-
332
- if delay_ms == 0:
333
- # Immediate update
334
- self._execute_update(widget_id, callback)
335
- else:
336
- # Debounced update
337
- if self._tk_root:
338
- after_id = self._tk_root.after(delay_ms,
339
- lambda: self._execute_update(widget_id, callback))
340
-
341
- # Track pending update
342
- self.pending_updates[widget_id] = PendingUpdate(
343
- widget_id=widget_id,
344
- event_info=event_info,
345
- callback=callback,
346
- after_id=after_id
347
- )
348
-
349
- def _execute_update(self, widget_id: str, callback: Callable) -> None:
350
- """
351
- Execute the statistics update callback.
352
-
353
- Args:
354
- widget_id: Widget identifier
355
- callback: Callback function to execute
356
- """
357
- try:
358
- # Remove from pending updates
359
- self.pending_updates.pop(widget_id, None)
360
-
361
- # Execute callback
362
- callback(widget_id)
363
-
364
- self.stats['updates_triggered'] += 1
365
-
366
- except Exception as e:
367
- # Log error if possible, but don't crash
368
- print(f"Error executing update callback for {widget_id}: {e}")
369
-
370
- def cancel_pending_updates(self, widget_id: str) -> None:
371
- """
372
- Cancel any pending updates for a specific widget.
373
-
374
- Args:
375
- widget_id: Widget identifier
376
- """
377
- pending = self.pending_updates.get(widget_id)
378
- if pending and pending.after_id and self._tk_root:
379
- try:
380
- self._tk_root.after_cancel(pending.after_id)
381
- except tk.TclError:
382
- pass # after_id might be invalid
383
-
384
- self.pending_updates.pop(widget_id, None)
385
-
386
- def cancel_all_pending_updates(self) -> None:
387
- """Cancel all pending updates."""
388
- for widget_id in list(self.pending_updates.keys()):
389
- self.cancel_pending_updates(widget_id)
390
-
391
- def set_debounce_strategy(self, strategy: DebounceStrategy) -> None:
392
- """
393
- Set the debouncing strategy for all widgets.
394
-
395
- Args:
396
- strategy: New debouncing strategy to use
397
- """
398
- self.debounce_config.strategy = strategy
399
-
400
- def set_debounce_config(self, config: DebounceConfig) -> None:
401
- """
402
- Set a new debounce configuration.
403
-
404
- Args:
405
- config: New debounce configuration
406
- """
407
- self.debounce_config = config
408
-
409
- def get_statistics(self) -> Dict[str, Any]:
410
- """
411
- Get event consolidation statistics.
412
-
413
- Returns:
414
- Dictionary with statistics about event handling
415
- """
416
- with self.event_lock:
417
- stats = self.stats.copy()
418
- stats.update({
419
- 'registered_widgets': len(self.registered_widgets),
420
- 'pending_updates': len(self.pending_updates),
421
- 'recent_events_tracked': len(self.recent_events),
422
- 'deduplication_rate': (
423
- self.stats['events_deduplicated'] / max(1, self.stats['events_received'])
424
- ) * 100
425
- })
426
- return stats
427
-
428
- def cleanup_expired_updates(self) -> None:
429
- """Clean up any expired pending updates."""
430
- expired_widgets = []
431
-
432
- for widget_id, pending in self.pending_updates.items():
433
- if pending.is_expired:
434
- expired_widgets.append(widget_id)
435
-
436
- for widget_id in expired_widgets:
437
- self.cancel_pending_updates(widget_id)
438
-
439
- def _generate_content_hash(self, content: str) -> str:
440
- """Generate a simple hash for content comparison."""
441
- # Simple hash based on length and first/last characters
442
- if not content:
443
- return "empty"
444
-
445
- content_clean = content.strip()
446
- if not content_clean:
447
- return "whitespace"
448
-
449
- # Create hash from length + first 10 + last 10 characters
450
- first_part = content_clean[:10] if len(content_clean) >= 10 else content_clean
451
- last_part = content_clean[-10:] if len(content_clean) >= 20 else ""
452
-
453
- return f"{len(content_clean)}_{hash(first_part + last_part) % 10000}"
454
-
455
- def get_widget_info(self, widget_id: str) -> Optional[Dict[str, Any]]:
456
- """
457
- Get information about a registered widget.
458
-
459
- Args:
460
- widget_id: Widget identifier
461
-
462
- Returns:
463
- Dictionary with widget information or None if not found
464
- """
465
- if widget_id not in self.registered_widgets:
466
- return None
467
-
468
- widget_ref = self.registered_widgets[widget_id]
469
- widget = widget_ref() if widget_ref else None
470
-
471
- recent_event = self.recent_events.get(widget_id)
472
- pending_update = self.pending_updates.get(widget_id)
473
-
474
- return {
475
- 'widget_id': widget_id,
476
- 'widget_exists': widget is not None,
477
- 'has_callback': widget_id in self.widget_callbacks,
478
- 'recent_event': {
479
- 'timestamp': recent_event.timestamp if recent_event else None,
480
- 'event_type': recent_event.event_type.value if recent_event else None,
481
- 'content_size': recent_event.content_size if recent_event else 0
482
- } if recent_event else None,
483
- 'pending_update': {
484
- 'scheduled_time': pending_update.scheduled_time,
485
- 'is_expired': pending_update.is_expired
486
- } if pending_update else None
487
- }
488
-
489
-
490
- # Global instance for easy access
491
- _global_event_consolidator: Optional[EventConsolidator] = None
492
-
493
-
494
- def get_event_consolidator() -> EventConsolidator:
495
- """Get the global event consolidator instance."""
496
- global _global_event_consolidator
497
- if _global_event_consolidator is None:
498
- _global_event_consolidator = EventConsolidator()
499
- return _global_event_consolidator
500
-
501
-
502
- def create_event_consolidator(debounce_config: Optional[DebounceConfig] = None) -> EventConsolidator:
503
- """
504
- Create a new event consolidator instance.
505
-
506
- Args:
507
- debounce_config: Optional debounce configuration
508
-
509
- Returns:
510
- New EventConsolidator instance
511
- """
1
+ """
2
+ Event Consolidation System for Pomera AI Commander.
3
+
4
+ This module provides intelligent event consolidation to replace multiple event handlers
5
+ with a single consolidated handler, implementing adaptive debouncing and event deduplication
6
+ to optimize statistics calculation performance.
7
+
8
+ Requirements addressed:
9
+ - 1.1: Consolidate multiple event triggers into single statistics update
10
+ - 1.2: Use intelligent debouncing to prevent excessive calculations
11
+ - 1.3: Increase debounce delays automatically for large content (>10,000 characters)
12
+ - 1.4: Prevent duplicate statistics calculations
13
+ """
14
+
15
+ import time
16
+ import threading
17
+ import tkinter as tk
18
+ from typing import Dict, Optional, Callable, Any, Set
19
+ from dataclasses import dataclass, field
20
+ from enum import Enum
21
+ import weakref
22
+
23
+
24
+ class EventType(Enum):
25
+ """Types of text widget events that trigger statistics updates."""
26
+ MODIFIED = "<<Modified>>"
27
+ KEY_RELEASE = "<KeyRelease>"
28
+ BUTTON_CLICK = "<Button-1>"
29
+ FOCUS_IN = "<FocusIn>"
30
+ FOCUS_OUT = "<FocusOut>"
31
+
32
+
33
+ class DebounceStrategy(Enum):
34
+ """Debouncing strategies for different content sizes and user behaviors."""
35
+ IMMEDIATE = "immediate" # No debouncing for very small changes
36
+ FAST = "fast" # 50ms for small content
37
+ NORMAL = "normal" # 300ms for medium content
38
+ SLOW = "slow" # 500ms for large content
39
+ ADAPTIVE = "adaptive" # Automatically adjust based on content size
40
+
41
+
42
+ @dataclass
43
+ class EventInfo:
44
+ """Information about a text widget event."""
45
+ widget_id: str
46
+ event_type: EventType
47
+ timestamp: float
48
+ content_size: int = 0
49
+ content_hash: str = ""
50
+
51
+ def __post_init__(self):
52
+ if not self.timestamp:
53
+ self.timestamp = time.time()
54
+
55
+
56
+ @dataclass
57
+ class DebounceConfig:
58
+ """Configuration for debouncing behavior."""
59
+ strategy: DebounceStrategy = DebounceStrategy.ADAPTIVE
60
+ immediate_threshold: int = 100 # Characters below which updates are immediate
61
+ fast_delay_ms: int = 50 # Delay for small content
62
+ normal_delay_ms: int = 300 # Delay for medium content
63
+ slow_delay_ms: int = 500 # Delay for large content
64
+ large_content_threshold: int = 10000 # Characters above which content is considered large
65
+ very_large_threshold: int = 100000 # Characters above which extra delays apply
66
+ max_delay_ms: int = 1000 # Maximum debounce delay
67
+
68
+ def get_delay_for_content_size(self, content_size: int) -> int:
69
+ """Get appropriate debounce delay based on content size."""
70
+ if self.strategy == DebounceStrategy.IMMEDIATE:
71
+ return 0
72
+ elif self.strategy == DebounceStrategy.FAST:
73
+ return self.fast_delay_ms
74
+ elif self.strategy == DebounceStrategy.NORMAL:
75
+ return self.normal_delay_ms
76
+ elif self.strategy == DebounceStrategy.SLOW:
77
+ return self.slow_delay_ms
78
+ elif self.strategy == DebounceStrategy.ADAPTIVE:
79
+ if content_size < self.immediate_threshold:
80
+ return 0
81
+ elif content_size < 1000:
82
+ return self.fast_delay_ms
83
+ elif content_size < self.large_content_threshold:
84
+ return self.normal_delay_ms
85
+ elif content_size < self.very_large_threshold:
86
+ return self.slow_delay_ms
87
+ else:
88
+ # Extra delay for very large content
89
+ return min(self.max_delay_ms, self.slow_delay_ms + 200)
90
+
91
+ return self.normal_delay_ms
92
+
93
+
94
+ @dataclass
95
+ class PendingUpdate:
96
+ """Information about a pending statistics update."""
97
+ widget_id: str
98
+ event_info: EventInfo
99
+ callback: Callable
100
+ after_id: Optional[str] = None
101
+ scheduled_time: float = field(default_factory=time.time)
102
+
103
+ @property
104
+ def is_expired(self) -> bool:
105
+ """Check if this update has been waiting too long."""
106
+ return (time.time() - self.scheduled_time) > 2.0 # 2 second timeout
107
+
108
+
109
+ class EventConsolidator:
110
+ """
111
+ Consolidates multiple text widget events into single statistics updates.
112
+
113
+ This class replaces multiple event handlers (<<Modified>>, <KeyRelease>, <Button-1>)
114
+ with a single consolidated handler that implements intelligent debouncing and
115
+ event deduplication to optimize performance.
116
+ """
117
+
118
+ def __init__(self, debounce_config: Optional[DebounceConfig] = None):
119
+ """
120
+ Initialize the event consolidator.
121
+
122
+ Args:
123
+ debounce_config: Configuration for debouncing behavior
124
+ """
125
+ self.debounce_config = debounce_config or DebounceConfig()
126
+
127
+ # Widget registry - use weak references to avoid memory leaks
128
+ self.registered_widgets: Dict[str, weakref.ref] = {}
129
+ self.widget_callbacks: Dict[str, Callable] = {}
130
+
131
+ # Event tracking for deduplication
132
+ self.recent_events: Dict[str, EventInfo] = {}
133
+ self.event_lock = threading.RLock()
134
+
135
+ # Pending updates tracking
136
+ self.pending_updates: Dict[str, PendingUpdate] = {}
137
+
138
+ # Statistics for monitoring
139
+ self.stats = {
140
+ 'events_received': 0,
141
+ 'events_deduplicated': 0,
142
+ 'updates_triggered': 0,
143
+ 'average_debounce_delay': 0.0
144
+ }
145
+
146
+ # Tkinter root reference for after() calls
147
+ self._tk_root: Optional[tk.Tk] = None
148
+
149
+ def set_tk_root(self, root: tk.Tk):
150
+ """Set the Tkinter root for scheduling callbacks."""
151
+ self._tk_root = root
152
+
153
+ def register_text_widget(self, widget_id: str, widget: tk.Text,
154
+ callback: Callable[[str], None]) -> None:
155
+ """
156
+ Register a text widget for consolidated event handling.
157
+
158
+ Args:
159
+ widget_id: Unique identifier for the widget
160
+ widget: The text widget to monitor
161
+ callback: Function to call when statistics should be updated
162
+ """
163
+ # Store weak reference to widget to avoid memory leaks
164
+ self.registered_widgets[widget_id] = weakref.ref(widget)
165
+ self.widget_callbacks[widget_id] = callback
166
+
167
+ # Bind single consolidated event handler
168
+ self._bind_consolidated_events(widget, widget_id)
169
+
170
+ def unregister_text_widget(self, widget_id: str) -> None:
171
+ """
172
+ Unregister a text widget and cancel any pending updates.
173
+
174
+ Args:
175
+ widget_id: Identifier of the widget to unregister
176
+ """
177
+ # Cancel pending updates
178
+ self.cancel_pending_updates(widget_id)
179
+
180
+ # Remove from registries
181
+ self.registered_widgets.pop(widget_id, None)
182
+ self.widget_callbacks.pop(widget_id, None)
183
+ self.recent_events.pop(widget_id, None)
184
+
185
+ def _bind_consolidated_events(self, widget: tk.Text, widget_id: str) -> None:
186
+ """
187
+ Bind consolidated event handler to replace multiple event bindings.
188
+
189
+ This replaces the multiple event bindings (<<Modified>>, <KeyRelease>, <Button-1>)
190
+ with a single handler per widget as required.
191
+ """
192
+ # Create consolidated event handler
193
+ def consolidated_handler(event=None):
194
+ event_type = self._determine_event_type(event)
195
+ self.handle_text_event(widget_id, event_type, event)
196
+ return None # Don't break event propagation
197
+
198
+ # Bind to all relevant events with single handler
199
+ widget.bind("<<Modified>>", consolidated_handler, add=True)
200
+ widget.bind("<KeyRelease>", consolidated_handler, add=True)
201
+ widget.bind("<Button-1>", consolidated_handler, add=True)
202
+
203
+ # Optional: Also handle focus events for better user experience
204
+ widget.bind("<FocusIn>", consolidated_handler, add=True)
205
+ widget.bind("<FocusOut>", consolidated_handler, add=True)
206
+
207
+ def _determine_event_type(self, event) -> EventType:
208
+ """Determine the type of event from the event object."""
209
+ if not event:
210
+ return EventType.MODIFIED
211
+
212
+ event_str = str(event.type) if hasattr(event, 'type') else str(event)
213
+
214
+ if "KeyRelease" in event_str or hasattr(event, 'keysym'):
215
+ return EventType.KEY_RELEASE
216
+ elif "Button" in event_str and hasattr(event, 'num'):
217
+ return EventType.BUTTON_CLICK
218
+ elif "FocusIn" in event_str:
219
+ return EventType.FOCUS_IN
220
+ elif "FocusOut" in event_str:
221
+ return EventType.FOCUS_OUT
222
+ else:
223
+ return EventType.MODIFIED
224
+
225
+ def handle_text_event(self, widget_id: str, event_type: EventType,
226
+ event=None) -> None:
227
+ """
228
+ Handle consolidated text widget events with intelligent deduplication.
229
+
230
+ Args:
231
+ widget_id: Identifier of the widget that triggered the event
232
+ event_type: Type of event that occurred
233
+ event: Original event object (optional)
234
+ """
235
+ with self.event_lock:
236
+ self.stats['events_received'] += 1
237
+
238
+ # Get widget reference
239
+ widget_ref = self.registered_widgets.get(widget_id)
240
+ if not widget_ref:
241
+ return
242
+
243
+ widget = widget_ref()
244
+ if not widget:
245
+ # Widget was garbage collected, clean up
246
+ self.unregister_text_widget(widget_id)
247
+ return
248
+
249
+ # Get current content for analysis
250
+ try:
251
+ content = widget.get("1.0", tk.END)
252
+ content_size = len(content.encode('utf-8'))
253
+ content_hash = self._generate_content_hash(content)
254
+ except tk.TclError:
255
+ # Widget might be destroyed
256
+ return
257
+
258
+ # Create event info
259
+ event_info = EventInfo(
260
+ widget_id=widget_id,
261
+ event_type=event_type,
262
+ timestamp=time.time(),
263
+ content_size=content_size,
264
+ content_hash=content_hash
265
+ )
266
+
267
+ # Check for duplicate events
268
+ if self._is_duplicate_event(widget_id, event_info):
269
+ self.stats['events_deduplicated'] += 1
270
+ return
271
+
272
+ # Store recent event for deduplication
273
+ self.recent_events[widget_id] = event_info
274
+
275
+ # Schedule update with appropriate debouncing
276
+ self._schedule_update(widget_id, event_info)
277
+
278
+ def _is_duplicate_event(self, widget_id: str, event_info: EventInfo) -> bool:
279
+ """
280
+ Check if this event is a duplicate that should be ignored.
281
+
282
+ Args:
283
+ widget_id: Widget identifier
284
+ event_info: Information about the current event
285
+
286
+ Returns:
287
+ True if this is a duplicate event that should be ignored
288
+ """
289
+ recent_event = self.recent_events.get(widget_id)
290
+ if not recent_event:
291
+ return False
292
+
293
+ # Check if content hasn't changed
294
+ if recent_event.content_hash == event_info.content_hash:
295
+ # Same content - check if enough time has passed
296
+ time_diff = event_info.timestamp - recent_event.timestamp
297
+ if time_diff < 0.1: # Less than 100ms
298
+ return True
299
+
300
+ # Check for rapid successive events of the same type
301
+ if (recent_event.event_type == event_info.event_type and
302
+ event_info.timestamp - recent_event.timestamp < 0.05): # Less than 50ms
303
+ return True
304
+
305
+ return False
306
+
307
+ def _schedule_update(self, widget_id: str, event_info: EventInfo) -> None:
308
+ """
309
+ Schedule a statistics update with appropriate debouncing.
310
+
311
+ Args:
312
+ widget_id: Widget identifier
313
+ event_info: Information about the event
314
+ """
315
+ # Cancel any existing pending update for this widget
316
+ self.cancel_pending_updates(widget_id)
317
+
318
+ # Get callback
319
+ callback = self.widget_callbacks.get(widget_id)
320
+ if not callback:
321
+ return
322
+
323
+ # Determine debounce delay
324
+ delay_ms = self.debounce_config.get_delay_for_content_size(event_info.content_size)
325
+
326
+ # Update statistics
327
+ self.stats['average_debounce_delay'] = (
328
+ (self.stats['average_debounce_delay'] * self.stats['updates_triggered'] + delay_ms) /
329
+ (self.stats['updates_triggered'] + 1)
330
+ )
331
+
332
+ if delay_ms == 0:
333
+ # Immediate update
334
+ self._execute_update(widget_id, callback)
335
+ else:
336
+ # Debounced update
337
+ if self._tk_root:
338
+ after_id = self._tk_root.after(delay_ms,
339
+ lambda: self._execute_update(widget_id, callback))
340
+
341
+ # Track pending update
342
+ self.pending_updates[widget_id] = PendingUpdate(
343
+ widget_id=widget_id,
344
+ event_info=event_info,
345
+ callback=callback,
346
+ after_id=after_id
347
+ )
348
+
349
+ def _execute_update(self, widget_id: str, callback: Callable) -> None:
350
+ """
351
+ Execute the statistics update callback.
352
+
353
+ Args:
354
+ widget_id: Widget identifier
355
+ callback: Callback function to execute
356
+ """
357
+ try:
358
+ # Remove from pending updates
359
+ self.pending_updates.pop(widget_id, None)
360
+
361
+ # Execute callback
362
+ callback(widget_id)
363
+
364
+ self.stats['updates_triggered'] += 1
365
+
366
+ except Exception as e:
367
+ # Log error if possible, but don't crash
368
+ print(f"Error executing update callback for {widget_id}: {e}")
369
+
370
+ def cancel_pending_updates(self, widget_id: str) -> None:
371
+ """
372
+ Cancel any pending updates for a specific widget.
373
+
374
+ Args:
375
+ widget_id: Widget identifier
376
+ """
377
+ pending = self.pending_updates.get(widget_id)
378
+ if pending and pending.after_id and self._tk_root:
379
+ try:
380
+ self._tk_root.after_cancel(pending.after_id)
381
+ except tk.TclError:
382
+ pass # after_id might be invalid
383
+
384
+ self.pending_updates.pop(widget_id, None)
385
+
386
+ def cancel_all_pending_updates(self) -> None:
387
+ """Cancel all pending updates."""
388
+ for widget_id in list(self.pending_updates.keys()):
389
+ self.cancel_pending_updates(widget_id)
390
+
391
+ def set_debounce_strategy(self, strategy: DebounceStrategy) -> None:
392
+ """
393
+ Set the debouncing strategy for all widgets.
394
+
395
+ Args:
396
+ strategy: New debouncing strategy to use
397
+ """
398
+ self.debounce_config.strategy = strategy
399
+
400
+ def set_debounce_config(self, config: DebounceConfig) -> None:
401
+ """
402
+ Set a new debounce configuration.
403
+
404
+ Args:
405
+ config: New debounce configuration
406
+ """
407
+ self.debounce_config = config
408
+
409
+ def get_statistics(self) -> Dict[str, Any]:
410
+ """
411
+ Get event consolidation statistics.
412
+
413
+ Returns:
414
+ Dictionary with statistics about event handling
415
+ """
416
+ with self.event_lock:
417
+ stats = self.stats.copy()
418
+ stats.update({
419
+ 'registered_widgets': len(self.registered_widgets),
420
+ 'pending_updates': len(self.pending_updates),
421
+ 'recent_events_tracked': len(self.recent_events),
422
+ 'deduplication_rate': (
423
+ self.stats['events_deduplicated'] / max(1, self.stats['events_received'])
424
+ ) * 100
425
+ })
426
+ return stats
427
+
428
+ def cleanup_expired_updates(self) -> None:
429
+ """Clean up any expired pending updates."""
430
+ expired_widgets = []
431
+
432
+ for widget_id, pending in self.pending_updates.items():
433
+ if pending.is_expired:
434
+ expired_widgets.append(widget_id)
435
+
436
+ for widget_id in expired_widgets:
437
+ self.cancel_pending_updates(widget_id)
438
+
439
+ def _generate_content_hash(self, content: str) -> str:
440
+ """Generate a simple hash for content comparison."""
441
+ # Simple hash based on length and first/last characters
442
+ if not content:
443
+ return "empty"
444
+
445
+ content_clean = content.strip()
446
+ if not content_clean:
447
+ return "whitespace"
448
+
449
+ # Create hash from length + first 10 + last 10 characters
450
+ first_part = content_clean[:10] if len(content_clean) >= 10 else content_clean
451
+ last_part = content_clean[-10:] if len(content_clean) >= 20 else ""
452
+
453
+ return f"{len(content_clean)}_{hash(first_part + last_part) % 10000}"
454
+
455
+ def get_widget_info(self, widget_id: str) -> Optional[Dict[str, Any]]:
456
+ """
457
+ Get information about a registered widget.
458
+
459
+ Args:
460
+ widget_id: Widget identifier
461
+
462
+ Returns:
463
+ Dictionary with widget information or None if not found
464
+ """
465
+ if widget_id not in self.registered_widgets:
466
+ return None
467
+
468
+ widget_ref = self.registered_widgets[widget_id]
469
+ widget = widget_ref() if widget_ref else None
470
+
471
+ recent_event = self.recent_events.get(widget_id)
472
+ pending_update = self.pending_updates.get(widget_id)
473
+
474
+ return {
475
+ 'widget_id': widget_id,
476
+ 'widget_exists': widget is not None,
477
+ 'has_callback': widget_id in self.widget_callbacks,
478
+ 'recent_event': {
479
+ 'timestamp': recent_event.timestamp if recent_event else None,
480
+ 'event_type': recent_event.event_type.value if recent_event else None,
481
+ 'content_size': recent_event.content_size if recent_event else 0
482
+ } if recent_event else None,
483
+ 'pending_update': {
484
+ 'scheduled_time': pending_update.scheduled_time,
485
+ 'is_expired': pending_update.is_expired
486
+ } if pending_update else None
487
+ }
488
+
489
+
490
+ # Global instance for easy access
491
+ _global_event_consolidator: Optional[EventConsolidator] = None
492
+
493
+
494
+ def get_event_consolidator() -> EventConsolidator:
495
+ """Get the global event consolidator instance."""
496
+ global _global_event_consolidator
497
+ if _global_event_consolidator is None:
498
+ _global_event_consolidator = EventConsolidator()
499
+ return _global_event_consolidator
500
+
501
+
502
+ def create_event_consolidator(debounce_config: Optional[DebounceConfig] = None) -> EventConsolidator:
503
+ """
504
+ Create a new event consolidator instance.
505
+
506
+ Args:
507
+ debounce_config: Optional debounce configuration
508
+
509
+ Returns:
510
+ New EventConsolidator instance
511
+ """
512
512
  return EventConsolidator(debounce_config)