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.
@@ -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
@@ -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
- # First check if we're running from the package itself
22
+ # Get the directory where this script is located
23
23
  script_dir = Path(__file__).parent.resolve()
24
- if (script_dir / "pomera.py").exists():
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 npm_path.exists():
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.5",
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.5",
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",