Add interactive fuzzy tool picker (cf command)
New `cf` command provides a fuzzy-searchable tool picker: - Type to filter tools by name or description - Arrow keys to navigate - Enter/Tab to select and run - ? to show arguments for selected tool - Escape to cancel Uses curses for a lightweight terminal UI with no extra dependencies. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3d87d888ab
commit
7f0dabd328
|
|
@ -63,6 +63,7 @@ all = [
|
|||
|
||||
[project.scripts]
|
||||
cmdforge = "cmdforge.cli:main"
|
||||
cf = "cmdforge.cli.picker:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://cmdforge.brrd.tech"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,273 @@
|
|||
"""Interactive fuzzy tool picker for CmdForge."""
|
||||
|
||||
import curses
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
from ..tool import list_tools, load_tool
|
||||
|
||||
|
||||
def fuzzy_match(query: str, text: str) -> Tuple[bool, int]:
|
||||
"""
|
||||
Check if query fuzzy-matches text.
|
||||
Returns (matches, score) where higher score = better match.
|
||||
"""
|
||||
if not query:
|
||||
return True, 0
|
||||
|
||||
query = query.lower()
|
||||
text = text.lower()
|
||||
|
||||
# Exact substring match gets highest score
|
||||
if query in text:
|
||||
# Bonus for matching at start
|
||||
if text.startswith(query):
|
||||
return True, 1000 + len(query)
|
||||
return True, 500 + len(query)
|
||||
|
||||
# Fuzzy match - all query chars must appear in order
|
||||
query_idx = 0
|
||||
score = 0
|
||||
prev_match_idx = -1
|
||||
|
||||
for i, char in enumerate(text):
|
||||
if query_idx < len(query) and char == query[query_idx]:
|
||||
# Bonus for consecutive matches
|
||||
if prev_match_idx == i - 1:
|
||||
score += 10
|
||||
else:
|
||||
score += 1
|
||||
prev_match_idx = i
|
||||
query_idx += 1
|
||||
|
||||
if query_idx == len(query):
|
||||
return True, score
|
||||
|
||||
return False, 0
|
||||
|
||||
|
||||
def get_tools_with_info() -> List[dict]:
|
||||
"""Get all tools with their info for display."""
|
||||
tools = []
|
||||
for name in list_tools():
|
||||
tool = load_tool(name)
|
||||
if tool:
|
||||
args = [arg.flag for arg in tool.arguments] if tool.arguments else []
|
||||
tools.append({
|
||||
"name": name,
|
||||
"description": tool.description or "",
|
||||
"category": tool.category or "Other",
|
||||
"arguments": args,
|
||||
})
|
||||
return sorted(tools, key=lambda t: t["name"])
|
||||
|
||||
|
||||
def run_picker() -> Optional[str]:
|
||||
"""
|
||||
Run the interactive fuzzy picker.
|
||||
Returns the selected tool name, or None if cancelled.
|
||||
"""
|
||||
tools = get_tools_with_info()
|
||||
|
||||
if not tools:
|
||||
print("No tools found. Create one with: cmdforge")
|
||||
return None
|
||||
|
||||
return curses.wrapper(lambda stdscr: _picker_loop(stdscr, tools))
|
||||
|
||||
|
||||
def _picker_loop(stdscr, tools: List[dict]) -> Optional[str]:
|
||||
"""Main picker loop using curses."""
|
||||
curses.curs_set(1) # Show cursor
|
||||
curses.use_default_colors()
|
||||
|
||||
# Initialize colors
|
||||
curses.init_pair(1, curses.COLOR_CYAN, -1) # Selected item
|
||||
curses.init_pair(2, curses.COLOR_YELLOW, -1) # Query
|
||||
curses.init_pair(3, curses.COLOR_GREEN, -1) # Match highlight
|
||||
curses.init_pair(4, 8, -1) # Dim (gray) - description
|
||||
|
||||
query = ""
|
||||
selected_idx = 0
|
||||
scroll_offset = 0
|
||||
|
||||
while True:
|
||||
stdscr.clear()
|
||||
height, width = stdscr.getmaxyx()
|
||||
|
||||
# Filter tools based on query
|
||||
filtered = []
|
||||
for tool in tools:
|
||||
# Match against name and description
|
||||
match_name, score_name = fuzzy_match(query, tool["name"])
|
||||
match_desc, score_desc = fuzzy_match(query, tool["description"])
|
||||
|
||||
if match_name or match_desc:
|
||||
score = max(score_name * 2, score_desc) # Prioritize name matches
|
||||
filtered.append((tool, score))
|
||||
|
||||
# Sort by score (highest first)
|
||||
filtered.sort(key=lambda x: x[1], reverse=True)
|
||||
filtered_tools = [t[0] for t in filtered]
|
||||
|
||||
# Adjust selection if out of bounds
|
||||
if selected_idx >= len(filtered_tools):
|
||||
selected_idx = max(0, len(filtered_tools) - 1)
|
||||
|
||||
# Calculate visible area (leave room for header and footer)
|
||||
list_height = height - 4
|
||||
|
||||
# Adjust scroll offset
|
||||
if selected_idx < scroll_offset:
|
||||
scroll_offset = selected_idx
|
||||
elif selected_idx >= scroll_offset + list_height:
|
||||
scroll_offset = selected_idx - list_height + 1
|
||||
|
||||
# Draw header
|
||||
header = f" CmdForge Tools ({len(filtered_tools)}/{len(tools)}) "
|
||||
stdscr.attron(curses.A_BOLD)
|
||||
stdscr.addstr(0, 0, header[:width-1])
|
||||
stdscr.attroff(curses.A_BOLD)
|
||||
|
||||
# Draw query line
|
||||
prompt = "> "
|
||||
stdscr.addstr(1, 0, prompt)
|
||||
stdscr.attron(curses.color_pair(2))
|
||||
stdscr.addstr(1, len(prompt), query[:width-len(prompt)-1])
|
||||
stdscr.attroff(curses.color_pair(2))
|
||||
|
||||
# Draw separator
|
||||
stdscr.addstr(2, 0, "─" * min(width-1, 60))
|
||||
|
||||
# Draw tool list
|
||||
visible_tools = filtered_tools[scroll_offset:scroll_offset + list_height]
|
||||
|
||||
for i, tool in enumerate(visible_tools):
|
||||
y = 3 + i
|
||||
if y >= height - 1:
|
||||
break
|
||||
|
||||
actual_idx = scroll_offset + i
|
||||
is_selected = actual_idx == selected_idx
|
||||
|
||||
# Build display line
|
||||
name = tool["name"]
|
||||
desc = tool["description"][:width-len(name)-5] if tool["description"] else ""
|
||||
|
||||
# Truncate if needed
|
||||
max_name_len = min(30, width // 3)
|
||||
if len(name) > max_name_len:
|
||||
name = name[:max_name_len-1] + "…"
|
||||
|
||||
if is_selected:
|
||||
stdscr.attron(curses.color_pair(1) | curses.A_BOLD)
|
||||
prefix = "▸ "
|
||||
else:
|
||||
prefix = " "
|
||||
|
||||
try:
|
||||
stdscr.addstr(y, 0, prefix)
|
||||
stdscr.addstr(y, 2, name)
|
||||
|
||||
if is_selected:
|
||||
stdscr.attroff(curses.color_pair(1) | curses.A_BOLD)
|
||||
|
||||
# Add description in gray
|
||||
if desc:
|
||||
desc_x = 2 + len(name) + 2
|
||||
if desc_x < width - 5:
|
||||
stdscr.attron(curses.color_pair(4))
|
||||
stdscr.addstr(y, desc_x, f"- {desc}"[:width-desc_x-1])
|
||||
stdscr.attroff(curses.color_pair(4))
|
||||
except curses.error:
|
||||
pass # Ignore drawing errors at screen edge
|
||||
|
||||
# Draw footer with help
|
||||
footer = " ↑↓:navigate Tab/Enter:select Esc:cancel ?:args "
|
||||
try:
|
||||
stdscr.attron(curses.A_DIM)
|
||||
stdscr.addstr(height-1, 0, footer[:width-1])
|
||||
stdscr.attroff(curses.A_DIM)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Position cursor at end of query
|
||||
try:
|
||||
stdscr.move(1, len(prompt) + len(query))
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
stdscr.refresh()
|
||||
|
||||
# Handle input
|
||||
try:
|
||||
key = stdscr.getch()
|
||||
except KeyboardInterrupt:
|
||||
return None
|
||||
|
||||
if key == 27: # Escape
|
||||
return None
|
||||
elif key in (curses.KEY_ENTER, 10, 13, ord('\t')): # Enter or Tab
|
||||
if filtered_tools:
|
||||
return filtered_tools[selected_idx]["name"]
|
||||
elif key == curses.KEY_UP:
|
||||
selected_idx = max(0, selected_idx - 1)
|
||||
elif key == curses.KEY_DOWN:
|
||||
selected_idx = min(len(filtered_tools) - 1, selected_idx + 1)
|
||||
elif key == curses.KEY_BACKSPACE or key == 127:
|
||||
query = query[:-1]
|
||||
selected_idx = 0
|
||||
scroll_offset = 0
|
||||
elif key == ord('?') and filtered_tools:
|
||||
# Show arguments for selected tool
|
||||
tool = filtered_tools[selected_idx]
|
||||
if tool["arguments"]:
|
||||
args_str = " ".join(tool["arguments"])
|
||||
stdscr.addstr(height-1, 0, f" Args: {args_str}"[:width-1] + " " * 20)
|
||||
stdscr.refresh()
|
||||
stdscr.getch() # Wait for any key
|
||||
elif 32 <= key <= 126: # Printable characters
|
||||
query += chr(key)
|
||||
selected_idx = 0
|
||||
scroll_offset = 0
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the cf command."""
|
||||
# Check if we're in a terminal
|
||||
if not sys.stdin.isatty():
|
||||
print("Error: cf requires an interactive terminal", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
selected = run_picker()
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if selected:
|
||||
# Print the command so user can see what was selected
|
||||
print(f"\033[1m{selected}\033[0m", file=sys.stderr)
|
||||
|
||||
# Check if stdin has data piped (it shouldn't since we're interactive)
|
||||
# Run the tool - it will read from stdin/user input
|
||||
try:
|
||||
# Use exec to replace this process with the tool
|
||||
# This way the tool can properly interact with the terminal
|
||||
tool_path = os.path.expanduser(f"~/.local/bin/{selected}")
|
||||
if os.path.exists(tool_path):
|
||||
os.execv(tool_path, [selected])
|
||||
else:
|
||||
# Fall back to running via cmdforge runner
|
||||
os.execlp("python", "python", "-m", "cmdforge.runner", selected)
|
||||
except Exception as e:
|
||||
# Fallback: just print the command
|
||||
print(f"Run: {selected}")
|
||||
else:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue