pomera-ai-commander 1.2.5 → 1.2.8
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.
- package/bin/pomera-create-shortcut.js +51 -0
- package/bin/pomera.js +68 -0
- package/core/backup_recovery_manager.py +223 -60
- package/core/collapsible_panel.py +197 -0
- package/core/database_settings_manager.py +20 -9
- package/core/migration_manager.py +12 -11
- package/core/settings_defaults_registry.py +8 -0
- package/core/tool_search_widget.py +497 -0
- package/create_shortcut.py +12 -4
- package/mcp.json +1 -1
- package/package.json +4 -2
- package/pomera.py +408 -72
- package/tools/base64_tools.py +4 -4
- package/tools/curl_history.py +45 -5
- package/tools/curl_tool.py +42 -12
- package/tools/diff_viewer.py +2 -9
- package/tools/notes_widget.py +8 -1
- package/tools/tool_loader.py +265 -24
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool Search Widget - Compact tool selector with popup dropdown.
|
|
3
|
+
|
|
4
|
+
This module provides a compact search bar that shows the current tool name
|
|
5
|
+
and opens a popup dropdown with search results when focused.
|
|
6
|
+
|
|
7
|
+
Author: Pomera AI Commander Team
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import tkinter as tk
|
|
11
|
+
from tkinter import ttk
|
|
12
|
+
from typing import Optional, Callable, List, Dict, Any
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
# Try to import rapidfuzz for fuzzy matching
|
|
16
|
+
try:
|
|
17
|
+
from rapidfuzz import fuzz, process
|
|
18
|
+
RAPIDFUZZ_AVAILABLE = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
RAPIDFUZZ_AVAILABLE = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ToolSearchPalette(ttk.Frame):
|
|
27
|
+
"""
|
|
28
|
+
Compact tool selector with popup dropdown.
|
|
29
|
+
|
|
30
|
+
Features:
|
|
31
|
+
- Compact bar showing current tool name
|
|
32
|
+
- Popup dropdown appears on click/focus
|
|
33
|
+
- Fuzzy search with keyboard navigation
|
|
34
|
+
- Dropdown hides on selection or click outside
|
|
35
|
+
|
|
36
|
+
Layout:
|
|
37
|
+
[🔍 Current Tool Name (Ctrl+K)]
|
|
38
|
+
↓ (on focus)
|
|
39
|
+
┌────────────────────────────────────┐
|
|
40
|
+
│ Search: [_____________] │
|
|
41
|
+
├────────────────────────────────────┤
|
|
42
|
+
│ ⭐ Case Tool │
|
|
43
|
+
│ Email Extraction - Extract... │
|
|
44
|
+
│ Hash Generator - Generate... │
|
|
45
|
+
└────────────────────────────────────┘
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
parent: tk.Widget,
|
|
51
|
+
tool_loader: Any,
|
|
52
|
+
on_tool_selected: Callable[[str], None],
|
|
53
|
+
settings: Optional[Dict[str, Any]] = None,
|
|
54
|
+
on_settings_change: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
55
|
+
**kwargs
|
|
56
|
+
):
|
|
57
|
+
super().__init__(parent, **kwargs)
|
|
58
|
+
|
|
59
|
+
self._tool_loader = tool_loader
|
|
60
|
+
self._on_tool_selected = on_tool_selected
|
|
61
|
+
self._settings = settings or {}
|
|
62
|
+
self._on_settings_change = on_settings_change
|
|
63
|
+
|
|
64
|
+
# Get UI layout settings
|
|
65
|
+
ui_layout = self._settings.get("ui_layout", {})
|
|
66
|
+
self._favorites: List[str] = list(ui_layout.get("favorite_tools", []))
|
|
67
|
+
self._recent: List[str] = list(ui_layout.get("recent_tools", []))
|
|
68
|
+
self._recent_max = ui_layout.get("recent_tools_max", 10)
|
|
69
|
+
|
|
70
|
+
# Current state
|
|
71
|
+
self._current_tool: str = self._settings.get("selected_tool", "Case Tool")
|
|
72
|
+
self._filtered_tools: List[str] = []
|
|
73
|
+
self._selected_index: int = 0
|
|
74
|
+
self._popup: Optional[tk.Toplevel] = None
|
|
75
|
+
self._popup_listbox: Optional[tk.Listbox] = None
|
|
76
|
+
self._closing: bool = False # Flag to prevent re-opening during close
|
|
77
|
+
|
|
78
|
+
self._create_widgets()
|
|
79
|
+
|
|
80
|
+
def _create_widgets(self) -> None:
|
|
81
|
+
"""Create the compact search bar (centered, half-width like VS Code)."""
|
|
82
|
+
# Centering frame
|
|
83
|
+
self.center_frame = ttk.Frame(self)
|
|
84
|
+
self.center_frame.pack(expand=True) # Centers horizontally
|
|
85
|
+
|
|
86
|
+
# Main bar frame (fixed width, centered)
|
|
87
|
+
self.bar_frame = ttk.Frame(self.center_frame)
|
|
88
|
+
self.bar_frame.pack()
|
|
89
|
+
|
|
90
|
+
# "Search Tools" label instead of icon
|
|
91
|
+
self.icon_label = ttk.Label(self.bar_frame, text="Search Tools", cursor="hand2")
|
|
92
|
+
self.icon_label.pack(side=tk.LEFT, padx=(5, 0))
|
|
93
|
+
self.icon_label.bind("<Button-1>", self._on_bar_click)
|
|
94
|
+
|
|
95
|
+
# StringVar for reliable text change detection
|
|
96
|
+
self._search_var = tk.StringVar(value=self._current_tool)
|
|
97
|
+
self._search_var.trace_add("write", self._on_search_change)
|
|
98
|
+
|
|
99
|
+
# Current tool name (wider entry for dropdown width + Ctrl+K hint space)
|
|
100
|
+
self.tool_entry = ttk.Entry(self.bar_frame, textvariable=self._search_var, width=65)
|
|
101
|
+
self.tool_entry.pack(side=tk.LEFT, padx=5)
|
|
102
|
+
|
|
103
|
+
# Make entry clickable to show dropdown
|
|
104
|
+
self.tool_entry.bind("<FocusIn>", self._on_entry_focus)
|
|
105
|
+
self.tool_entry.bind("<Return>", self._on_enter)
|
|
106
|
+
self.tool_entry.bind("<Escape>", self._hide_popup)
|
|
107
|
+
self.tool_entry.bind("<Down>", self._on_key_down)
|
|
108
|
+
self.tool_entry.bind("<Up>", self._on_key_up)
|
|
109
|
+
|
|
110
|
+
# Shortcut button (clickable, focuses search)
|
|
111
|
+
self.hint_button = ttk.Button(self.bar_frame, text="(Ctrl+K)", width=8,
|
|
112
|
+
command=self.focus_search)
|
|
113
|
+
self.hint_button.pack(side=tk.RIGHT, padx=(0, 5))
|
|
114
|
+
|
|
115
|
+
# Bind to MAIN WINDOW Configure event for resize/move handling
|
|
116
|
+
self.winfo_toplevel().bind("<Configure>", self._on_main_window_configure, add="+")
|
|
117
|
+
|
|
118
|
+
def _on_search_change(self, *args) -> None:
|
|
119
|
+
"""Handle text changes in search entry (via StringVar trace)."""
|
|
120
|
+
# Don't show popup during close operation
|
|
121
|
+
if self._closing:
|
|
122
|
+
return
|
|
123
|
+
# Show popup if not visible
|
|
124
|
+
if not (self._popup and self._popup.winfo_exists()):
|
|
125
|
+
self._show_popup()
|
|
126
|
+
else:
|
|
127
|
+
self._update_popup_list()
|
|
128
|
+
|
|
129
|
+
def _on_main_window_configure(self, event=None) -> None:
|
|
130
|
+
"""Handle main window resize/move - hide popup to prevent floating."""
|
|
131
|
+
if self._popup and self._popup.winfo_exists():
|
|
132
|
+
self._hide_popup()
|
|
133
|
+
|
|
134
|
+
def _on_bar_click(self, event=None) -> None:
|
|
135
|
+
"""Handle click on the bar - show dropdown."""
|
|
136
|
+
self.tool_entry.focus_set()
|
|
137
|
+
self._show_popup()
|
|
138
|
+
|
|
139
|
+
def _on_configure(self, event=None) -> None:
|
|
140
|
+
"""Handle window resize/move - hide popup to prevent floating."""
|
|
141
|
+
if self._popup and self._popup.winfo_exists():
|
|
142
|
+
self._hide_popup()
|
|
143
|
+
|
|
144
|
+
def _on_entry_focus(self, event=None) -> None:
|
|
145
|
+
"""Handle focus on entry - clear text and show dropdown."""
|
|
146
|
+
# Clear search field to allow fresh search
|
|
147
|
+
self._search_var.set("")
|
|
148
|
+
self._show_popup()
|
|
149
|
+
|
|
150
|
+
def _show_popup(self) -> None:
|
|
151
|
+
"""Show the dropdown popup below the search bar."""
|
|
152
|
+
if self._popup and self._popup.winfo_exists():
|
|
153
|
+
self._update_popup_list()
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
# Create popup window
|
|
157
|
+
self._popup = tk.Toplevel(self)
|
|
158
|
+
self._popup.wm_overrideredirect(True) # No window decorations
|
|
159
|
+
self._popup.wm_attributes("-topmost", True)
|
|
160
|
+
|
|
161
|
+
# Position below the entry
|
|
162
|
+
x = self.tool_entry.winfo_rootx()
|
|
163
|
+
y = self.tool_entry.winfo_rooty() + self.tool_entry.winfo_height()
|
|
164
|
+
width = max(400, self.tool_entry.winfo_width() + 50)
|
|
165
|
+
self._popup.geometry(f"{width}x250+{x}+{y}")
|
|
166
|
+
|
|
167
|
+
# Popup content frame
|
|
168
|
+
popup_frame = ttk.Frame(self._popup, relief="solid", borderwidth=1)
|
|
169
|
+
popup_frame.pack(fill=tk.BOTH, expand=True)
|
|
170
|
+
|
|
171
|
+
# Header frame with title only
|
|
172
|
+
header_frame = ttk.Frame(popup_frame)
|
|
173
|
+
header_frame.pack(fill=tk.X, padx=2, pady=(2, 0))
|
|
174
|
+
|
|
175
|
+
# Header label
|
|
176
|
+
ttk.Label(header_frame, text="Select Tool", font=("TkDefaultFont", 9, "bold")).pack(side=tk.LEFT, padx=5)
|
|
177
|
+
|
|
178
|
+
# Separator under header
|
|
179
|
+
ttk.Separator(popup_frame, orient="horizontal").pack(fill=tk.X, padx=2, pady=2)
|
|
180
|
+
|
|
181
|
+
# Results listbox
|
|
182
|
+
self._popup_listbox = tk.Listbox(
|
|
183
|
+
popup_frame,
|
|
184
|
+
selectmode=tk.SINGLE,
|
|
185
|
+
font=("TkDefaultFont", 10),
|
|
186
|
+
activestyle="dotbox"
|
|
187
|
+
)
|
|
188
|
+
self._popup_listbox.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
|
|
189
|
+
|
|
190
|
+
# Scrollbar
|
|
191
|
+
scrollbar = ttk.Scrollbar(popup_frame, command=self._popup_listbox.yview)
|
|
192
|
+
scrollbar.place(relx=1.0, rely=0, relheight=0.85, anchor="ne")
|
|
193
|
+
self._popup_listbox.configure(yscrollcommand=scrollbar.set)
|
|
194
|
+
|
|
195
|
+
# Footer frame with close button at bottom right
|
|
196
|
+
footer_frame = ttk.Frame(popup_frame)
|
|
197
|
+
footer_frame.pack(fill=tk.X, padx=2, pady=(0, 2))
|
|
198
|
+
|
|
199
|
+
# Close button (X) - positioned at bottom right
|
|
200
|
+
close_btn = ttk.Button(footer_frame, text="Close", width=8, command=self._close_and_select_default)
|
|
201
|
+
close_btn.pack(side=tk.RIGHT, padx=2)
|
|
202
|
+
|
|
203
|
+
# Bind events
|
|
204
|
+
self._popup_listbox.bind("<Double-Button-1>", self._on_listbox_select)
|
|
205
|
+
self._popup_listbox.bind("<Return>", self._on_listbox_select)
|
|
206
|
+
|
|
207
|
+
# Close popup when clicking outside
|
|
208
|
+
self._popup.bind("<FocusOut>", self._on_popup_focus_out)
|
|
209
|
+
self.tool_entry.bind("<FocusOut>", self._on_entry_focus_out)
|
|
210
|
+
|
|
211
|
+
# Populate list
|
|
212
|
+
self._update_popup_list()
|
|
213
|
+
|
|
214
|
+
# Sub-tools that are nested within parent categories (for indentation)
|
|
215
|
+
SUB_TOOLS = {
|
|
216
|
+
"Slug Generator", "Hash Generator", "ASCII Art Generator",
|
|
217
|
+
"URL Link Extractor", "Regex Extractor", "Email Extraction",
|
|
218
|
+
"HTML Tool",
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
def _update_popup_list(self) -> None:
|
|
222
|
+
"""Update the popup listbox with filtered tools."""
|
|
223
|
+
if not self._popup_listbox:
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
# Get search query
|
|
227
|
+
query = self._search_var.get().strip()
|
|
228
|
+
|
|
229
|
+
# Get grouped tools (processing tools only, with parent-child hierarchy)
|
|
230
|
+
if self._tool_loader:
|
|
231
|
+
grouped_tools = self._tool_loader.get_grouped_tools()
|
|
232
|
+
all_tools = [t[0] for t in grouped_tools]
|
|
233
|
+
else:
|
|
234
|
+
grouped_tools = []
|
|
235
|
+
all_tools = []
|
|
236
|
+
|
|
237
|
+
# Filter by search query or show all grouped
|
|
238
|
+
if query and query != self._current_tool:
|
|
239
|
+
# Search mode - fuzzy search all tools
|
|
240
|
+
matched = self._fuzzy_search(all_tools, query)
|
|
241
|
+
self._filtered_tools = matched
|
|
242
|
+
# Build display with is_sub_tool info
|
|
243
|
+
tool_info = {t[0]: t[1] for t in grouped_tools}
|
|
244
|
+
display_tools = [(t, tool_info.get(t, False)) for t in matched]
|
|
245
|
+
else:
|
|
246
|
+
# Default mode - show grouped tools in order
|
|
247
|
+
display_tools = grouped_tools
|
|
248
|
+
self._filtered_tools = [t[0] for t in grouped_tools]
|
|
249
|
+
|
|
250
|
+
# Update listbox
|
|
251
|
+
self._popup_listbox.delete(0, tk.END)
|
|
252
|
+
for tool, is_sub_tool in display_tools:
|
|
253
|
+
# Format sub-tools with arrow prefix
|
|
254
|
+
if is_sub_tool:
|
|
255
|
+
indent = " ↳ " # Arrow prefix for sub-tools
|
|
256
|
+
else:
|
|
257
|
+
indent = ""
|
|
258
|
+
|
|
259
|
+
prefix = "⭐ " if tool in self._favorites else ""
|
|
260
|
+
|
|
261
|
+
# Get description
|
|
262
|
+
desc = ""
|
|
263
|
+
if self._tool_loader:
|
|
264
|
+
spec = self._tool_loader.get_tool_spec(tool)
|
|
265
|
+
if spec and spec.description:
|
|
266
|
+
desc = f" - {spec.description[:35]}..."
|
|
267
|
+
|
|
268
|
+
self._popup_listbox.insert(tk.END, f"{indent}{prefix}{tool}{desc}")
|
|
269
|
+
|
|
270
|
+
# Select first item
|
|
271
|
+
if self._filtered_tools:
|
|
272
|
+
self._selected_index = 0
|
|
273
|
+
self._popup_listbox.selection_set(0)
|
|
274
|
+
|
|
275
|
+
def _fuzzy_search(self, tools: List[str], query: str) -> List[str]:
|
|
276
|
+
"""Perform fuzzy search on tool names."""
|
|
277
|
+
if not query:
|
|
278
|
+
return tools
|
|
279
|
+
|
|
280
|
+
query_lower = query.lower()
|
|
281
|
+
|
|
282
|
+
# For very short queries (1-2 chars), use prefix matching only to avoid noise
|
|
283
|
+
if len(query) <= 2:
|
|
284
|
+
matches = [t for t in tools if t.lower().startswith(query_lower)]
|
|
285
|
+
if matches:
|
|
286
|
+
return matches
|
|
287
|
+
# Fallback to contains if no prefix matches
|
|
288
|
+
return [t for t in tools if query_lower in t.lower()]
|
|
289
|
+
|
|
290
|
+
if RAPIDFUZZ_AVAILABLE:
|
|
291
|
+
search_data = {}
|
|
292
|
+
for tool in tools:
|
|
293
|
+
search_text = tool
|
|
294
|
+
if self._tool_loader:
|
|
295
|
+
spec = self._tool_loader.get_tool_spec(tool)
|
|
296
|
+
if spec and spec.description:
|
|
297
|
+
search_text = f"{tool} {spec.description}"
|
|
298
|
+
# Use lowercase for case-insensitive matching
|
|
299
|
+
search_data[tool] = search_text.lower()
|
|
300
|
+
|
|
301
|
+
# Convert query to lowercase for case-insensitive search
|
|
302
|
+
# Threshold set to 50 to allow substring matches
|
|
303
|
+
results = process.extract(query_lower, search_data, scorer=fuzz.WRatio, limit=15)
|
|
304
|
+
fuzzy_matches = [match[2] for match in results if match[1] >= 50]
|
|
305
|
+
|
|
306
|
+
# Also include any tools that contain the query as substring (ensures "URL" finds "URL Parser")
|
|
307
|
+
substring_matches = [t for t in tools if query_lower in t.lower()]
|
|
308
|
+
for tool in substring_matches:
|
|
309
|
+
if tool not in fuzzy_matches:
|
|
310
|
+
fuzzy_matches.append(tool)
|
|
311
|
+
|
|
312
|
+
# Prioritize exact prefix matches at the top
|
|
313
|
+
prefix_matches = [t for t in fuzzy_matches if t.lower().startswith(query_lower)]
|
|
314
|
+
other_matches = [t for t in fuzzy_matches if not t.lower().startswith(query_lower)]
|
|
315
|
+
|
|
316
|
+
return prefix_matches + other_matches
|
|
317
|
+
else:
|
|
318
|
+
# Fallback substring matching
|
|
319
|
+
matches = [(t, t.lower().find(query_lower)) for t in tools if query_lower in t.lower()]
|
|
320
|
+
matches.sort(key=lambda x: x[1])
|
|
321
|
+
return [m[0] for m in matches]
|
|
322
|
+
|
|
323
|
+
def _on_search_key(self, event=None) -> None:
|
|
324
|
+
"""Handle key press in search entry."""
|
|
325
|
+
if event and event.keysym in ("Up", "Down", "Return", "Escape"):
|
|
326
|
+
return # Handled separately
|
|
327
|
+
self._update_popup_list()
|
|
328
|
+
|
|
329
|
+
def _on_key_down(self, event=None) -> str:
|
|
330
|
+
"""Handle down arrow."""
|
|
331
|
+
if self._popup_listbox and self._filtered_tools:
|
|
332
|
+
if self._selected_index < len(self._filtered_tools) - 1:
|
|
333
|
+
self._selected_index += 1
|
|
334
|
+
self._popup_listbox.selection_clear(0, tk.END)
|
|
335
|
+
self._popup_listbox.selection_set(self._selected_index)
|
|
336
|
+
self._popup_listbox.see(self._selected_index)
|
|
337
|
+
return "break"
|
|
338
|
+
|
|
339
|
+
def _on_key_up(self, event=None) -> str:
|
|
340
|
+
"""Handle up arrow."""
|
|
341
|
+
if self._popup_listbox and self._filtered_tools:
|
|
342
|
+
if self._selected_index > 0:
|
|
343
|
+
self._selected_index -= 1
|
|
344
|
+
self._popup_listbox.selection_clear(0, tk.END)
|
|
345
|
+
self._popup_listbox.selection_set(self._selected_index)
|
|
346
|
+
self._popup_listbox.see(self._selected_index)
|
|
347
|
+
return "break"
|
|
348
|
+
|
|
349
|
+
def _on_enter(self, event=None) -> str:
|
|
350
|
+
"""Handle Enter key - select current tool."""
|
|
351
|
+
if self._filtered_tools and 0 <= self._selected_index < len(self._filtered_tools):
|
|
352
|
+
tool = self._filtered_tools[self._selected_index]
|
|
353
|
+
self._select_tool(tool)
|
|
354
|
+
return "break"
|
|
355
|
+
|
|
356
|
+
def _on_listbox_select(self, event=None) -> None:
|
|
357
|
+
"""Handle listbox selection."""
|
|
358
|
+
if not self._popup_listbox:
|
|
359
|
+
return
|
|
360
|
+
selection = self._popup_listbox.curselection()
|
|
361
|
+
if selection and selection[0] < len(self._filtered_tools):
|
|
362
|
+
tool = self._filtered_tools[selection[0]]
|
|
363
|
+
self._select_tool(tool)
|
|
364
|
+
|
|
365
|
+
def _select_tool(self, tool_name: str) -> None:
|
|
366
|
+
"""Select a tool and hide popup."""
|
|
367
|
+
self._current_tool = tool_name
|
|
368
|
+
|
|
369
|
+
# Update entry to show selected tool (use StringVar)
|
|
370
|
+
self._search_var.set(tool_name)
|
|
371
|
+
|
|
372
|
+
# Update recent tools
|
|
373
|
+
if tool_name in self._recent:
|
|
374
|
+
self._recent.remove(tool_name)
|
|
375
|
+
self._recent.insert(0, tool_name)
|
|
376
|
+
self._recent = self._recent[:self._recent_max]
|
|
377
|
+
|
|
378
|
+
# Hide popup
|
|
379
|
+
self._hide_popup()
|
|
380
|
+
|
|
381
|
+
# Remove focus from search entry so clicking again shows dropdown
|
|
382
|
+
self.winfo_toplevel().focus_set()
|
|
383
|
+
|
|
384
|
+
# Save settings
|
|
385
|
+
self._save_settings()
|
|
386
|
+
|
|
387
|
+
# Notify callback
|
|
388
|
+
if self._on_tool_selected:
|
|
389
|
+
try:
|
|
390
|
+
self._on_tool_selected(tool_name)
|
|
391
|
+
except Exception as e:
|
|
392
|
+
logger.error(f"Error in on_tool_selected callback: {e}")
|
|
393
|
+
|
|
394
|
+
def _hide_popup(self, event=None) -> str:
|
|
395
|
+
"""Hide the popup dropdown and restore tool name."""
|
|
396
|
+
if self._popup and self._popup.winfo_exists():
|
|
397
|
+
self._popup.destroy()
|
|
398
|
+
self._popup = None
|
|
399
|
+
self._popup_listbox = None
|
|
400
|
+
|
|
401
|
+
# Restore current tool name if search field is empty or contains partial text
|
|
402
|
+
if not self._search_var.get().strip() or self._search_var.get() != self._current_tool:
|
|
403
|
+
self._search_var.set(self._current_tool)
|
|
404
|
+
|
|
405
|
+
return "break"
|
|
406
|
+
|
|
407
|
+
def _close_and_select_default(self) -> None:
|
|
408
|
+
"""Close popup and select default tool if no tool selected."""
|
|
409
|
+
# Set closing flag to prevent focus events from re-opening
|
|
410
|
+
self._closing = True
|
|
411
|
+
|
|
412
|
+
# Destroy popup FIRST to prevent any refresh loops
|
|
413
|
+
if self._popup and self._popup.winfo_exists():
|
|
414
|
+
self._popup.destroy()
|
|
415
|
+
self._popup = None
|
|
416
|
+
self._popup_listbox = None
|
|
417
|
+
|
|
418
|
+
# If no current tool, default to AI Tools
|
|
419
|
+
if not self._current_tool or self._current_tool == "":
|
|
420
|
+
self._current_tool = "AI Tools"
|
|
421
|
+
if self._on_tool_selected:
|
|
422
|
+
try:
|
|
423
|
+
self._on_tool_selected(self._current_tool)
|
|
424
|
+
except Exception as e:
|
|
425
|
+
logger.error(f"Error in on_tool_selected callback: {e}")
|
|
426
|
+
|
|
427
|
+
# Set the search var to current tool (popup already destroyed, won't trigger refresh)
|
|
428
|
+
self._search_var.set(self._current_tool)
|
|
429
|
+
|
|
430
|
+
# Remove focus from entry
|
|
431
|
+
self.winfo_toplevel().focus_set()
|
|
432
|
+
|
|
433
|
+
# Reset closing flag after a delay
|
|
434
|
+
self.after(200, self._reset_closing_flag)
|
|
435
|
+
|
|
436
|
+
def _reset_closing_flag(self) -> None:
|
|
437
|
+
"""Reset the closing flag."""
|
|
438
|
+
self._closing = False
|
|
439
|
+
|
|
440
|
+
def _on_popup_focus_out(self, event=None) -> None:
|
|
441
|
+
"""Handle focus leaving popup."""
|
|
442
|
+
# Delay to check if focus went to entry
|
|
443
|
+
self.after(100, self._check_focus)
|
|
444
|
+
|
|
445
|
+
def _on_entry_focus_out(self, event=None) -> None:
|
|
446
|
+
"""Handle focus leaving entry."""
|
|
447
|
+
# Delay to check if focus went to popup
|
|
448
|
+
self.after(100, self._check_focus)
|
|
449
|
+
|
|
450
|
+
def _check_focus(self) -> None:
|
|
451
|
+
"""Check if focus is on entry or popup, hide if not."""
|
|
452
|
+
# Don't interfere during close operation
|
|
453
|
+
if self._closing:
|
|
454
|
+
return
|
|
455
|
+
try:
|
|
456
|
+
focused = self.focus_get()
|
|
457
|
+
if focused not in (self.tool_entry, self._popup_listbox):
|
|
458
|
+
if self._popup and self._popup.winfo_exists():
|
|
459
|
+
# Check if focus is inside popup
|
|
460
|
+
try:
|
|
461
|
+
if not str(focused).startswith(str(self._popup)):
|
|
462
|
+
self._hide_popup()
|
|
463
|
+
except:
|
|
464
|
+
pass
|
|
465
|
+
except:
|
|
466
|
+
pass
|
|
467
|
+
|
|
468
|
+
def _save_settings(self) -> None:
|
|
469
|
+
"""Save favorites and recent to settings."""
|
|
470
|
+
if self._on_settings_change:
|
|
471
|
+
try:
|
|
472
|
+
self._on_settings_change({
|
|
473
|
+
"ui_layout": {
|
|
474
|
+
"favorite_tools": self._favorites.copy(),
|
|
475
|
+
"recent_tools": self._recent.copy(),
|
|
476
|
+
}
|
|
477
|
+
})
|
|
478
|
+
except Exception as e:
|
|
479
|
+
logger.error(f"Error saving settings: {e}")
|
|
480
|
+
|
|
481
|
+
def focus_search(self) -> None:
|
|
482
|
+
"""Focus the search entry (Ctrl+K shortcut)."""
|
|
483
|
+
self.tool_entry.focus_set()
|
|
484
|
+
self.tool_entry.selection_range(0, tk.END)
|
|
485
|
+
self._show_popup()
|
|
486
|
+
|
|
487
|
+
def get_selected_tool(self) -> Optional[str]:
|
|
488
|
+
"""Get the currently selected tool name."""
|
|
489
|
+
return self._current_tool
|
|
490
|
+
|
|
491
|
+
def set_tool_loader(self, loader: Any) -> None:
|
|
492
|
+
"""Update the tool loader."""
|
|
493
|
+
self._tool_loader = loader
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
# Module availability flag
|
|
497
|
+
TOOL_SEARCH_WIDGET_AVAILABLE = True
|
package/create_shortcut.py
CHANGED
|
@@ -19,12 +19,15 @@ from pathlib import Path
|
|
|
19
19
|
|
|
20
20
|
def get_package_dir() -> Path:
|
|
21
21
|
"""Get the pomera-ai-commander package directory."""
|
|
22
|
-
#
|
|
22
|
+
# Get the directory where this script is located
|
|
23
23
|
script_dir = Path(__file__).parent.resolve()
|
|
24
|
-
|
|
24
|
+
|
|
25
|
+
# Check if we're already running from an npm installation
|
|
26
|
+
# (script_dir will be inside node_modules/pomera-ai-commander)
|
|
27
|
+
if "node_modules" in str(script_dir) and (script_dir / "pomera.py").exists():
|
|
25
28
|
return script_dir
|
|
26
29
|
|
|
27
|
-
# Check npm global installation
|
|
30
|
+
# Check npm global installation paths
|
|
28
31
|
npm_global = os.environ.get("APPDATA", "") or os.path.expanduser("~")
|
|
29
32
|
if platform.system() == "Windows":
|
|
30
33
|
npm_path = Path(npm_global) / "npm" / "node_modules" / "pomera-ai-commander"
|
|
@@ -35,9 +38,14 @@ def get_package_dir() -> Path:
|
|
|
35
38
|
if not npm_path.exists():
|
|
36
39
|
npm_path = Path.home() / ".npm-packages" / "lib" / "node_modules" / "pomera-ai-commander"
|
|
37
40
|
|
|
38
|
-
if
|
|
41
|
+
# Prefer npm installation if it exists
|
|
42
|
+
if npm_path.exists() and (npm_path / "pomera.py").exists():
|
|
39
43
|
return npm_path
|
|
40
44
|
|
|
45
|
+
# Fallback to script's directory (for pip install or direct run)
|
|
46
|
+
if (script_dir / "pomera.py").exists():
|
|
47
|
+
return script_dir
|
|
48
|
+
|
|
41
49
|
return script_dir
|
|
42
50
|
|
|
43
51
|
|
package/mcp.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pomera-ai-commander",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.8",
|
|
4
4
|
"description": "Text processing toolkit with 22 MCP tools including case transformation, encoding, hashing, text analysis, and notes management for AI assistants.",
|
|
5
5
|
"homepage": "https://github.com/matbanik/Pomera-AI-Commander",
|
|
6
6
|
"repository": "https://github.com/matbanik/Pomera-AI-Commander",
|
package/package.json
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pomera-ai-commander",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.8",
|
|
4
4
|
"description": "Text processing toolkit with 22 MCP tools for AI assistants - case transformation, encoding, hashing, text analysis, and notes management",
|
|
5
5
|
"main": "pomera_mcp_server.py",
|
|
6
6
|
"bin": {
|
|
7
7
|
"pomera-ai-commander": "./bin/pomera-ai-commander.js",
|
|
8
|
-
"pomera-mcp": "./bin/pomera-ai-commander.js"
|
|
8
|
+
"pomera-mcp": "./bin/pomera-ai-commander.js",
|
|
9
|
+
"pomera": "./bin/pomera.js",
|
|
10
|
+
"pomera-create-shortcut": "./bin/pomera-create-shortcut.js"
|
|
9
11
|
},
|
|
10
12
|
"scripts": {
|
|
11
13
|
"start": "python pomera_mcp_server.py",
|