Rewrite cf as lightweight inline dropdown

- No longer fullscreen - just a small dropdown below cursor
- Shows max 8 items at a time with "+N more" indicator
- No curses dependency - uses simple ANSI escape codes
- Minimal UI: just type to filter, arrows to navigate
- Cleans up after itself when done
- Much more integrated terminal feel

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-18 02:46:04 -04:00
parent 992c473939
commit df1d3ace23
1 changed files with 250 additions and 359 deletions

View File

@ -1,9 +1,9 @@
"""Interactive fuzzy tool picker for CmdForge.""" """Interactive fuzzy tool picker for CmdForge - inline dropdown style."""
import curses
import sys import sys
import os import os
import subprocess import tty
import termios
from typing import List, Tuple, Optional from typing import List, Tuple, Optional
from dataclasses import dataclass from dataclasses import dataclass
@ -14,416 +14,307 @@ from ..tool import list_tools, load_tool
class PickerResult: class PickerResult:
"""Result from the picker.""" """Result from the picker."""
tool_name: str tool_name: str
arguments: dict # flag -> value arguments: dict
# ANSI escape codes
CLEAR_LINE = "\033[2K"
MOVE_UP = "\033[A"
HIDE_CURSOR = "\033[?25l"
SHOW_CURSOR = "\033[?25h"
BOLD = "\033[1m"
DIM = "\033[2m"
CYAN = "\033[36m"
YELLOW = "\033[33m"
GREEN = "\033[32m"
RESET = "\033[0m"
MAX_VISIBLE = 8 # Show at most 8 items
def fuzzy_match(query: str, text: str) -> Tuple[bool, int]: def fuzzy_match(query: str, text: str) -> Tuple[bool, int]:
""" """Fuzzy match with scoring."""
Check if query fuzzy-matches text.
Returns (matches, score) where higher score = better match.
"""
if not query: if not query:
return True, 0 return True, 0
query = query.lower() query = query.lower()
text = text.lower() text = text.lower()
# Exact substring match gets highest score
if query in text: if query in text:
# Bonus for matching at start
if text.startswith(query): if text.startswith(query):
return True, 1000 + len(query) return True, 1000 + len(query)
return True, 500 + len(query) return True, 500 + len(query)
# Fuzzy match - all query chars must appear in order # Character-by-character fuzzy match
query_idx = 0 qi = 0
score = 0 score = 0
prev_match_idx = -1 for i, c in enumerate(text):
if qi < len(query) and c == query[qi]:
score += 10 if i == 0 or text[i-1] in ' -_' else 1
qi += 1
for i, char in enumerate(text): return (qi == len(query), score) if qi == len(query) else (False, 0)
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]: def get_tools() -> List[dict]:
"""Get all tools with their info for display.""" """Get all tools with info."""
tools = [] tools = []
for name in list_tools(): for name in list_tools():
tool = load_tool(name) tool = load_tool(name)
if tool: if tool:
args = []
for arg in tool.arguments:
args.append({
"flag": arg.flag,
"variable": arg.variable,
"default": arg.default or "",
"description": arg.description or "",
})
tools.append({ tools.append({
"name": name, "name": name,
"description": tool.description or "", "desc": (tool.description or "")[:50],
"category": tool.category or "Other", "args": [{
"arguments": args, "flag": a.flag,
"default": a.default or "",
"desc": a.description or ""
} for a in tool.arguments]
}) })
return sorted(tools, key=lambda t: t["name"]) return sorted(tools, key=lambda t: t["name"])
def getch():
"""Read a single character from stdin."""
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
ch = sys.stdin.read(1)
# Handle escape sequences (arrow keys)
if ch == '\x1b':
ch2 = sys.stdin.read(1)
if ch2 == '[':
ch3 = sys.stdin.read(1)
if ch3 == 'A': return 'UP'
if ch3 == 'B': return 'DOWN'
if ch3 == 'C': return 'RIGHT'
if ch3 == 'D': return 'LEFT'
return ch
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def clear_dropdown(n_lines: int):
"""Clear the dropdown lines we drew."""
for _ in range(n_lines):
sys.stdout.write(MOVE_UP + CLEAR_LINE)
sys.stdout.write('\r')
sys.stdout.flush()
def run_picker() -> Optional[PickerResult]: def run_picker() -> Optional[PickerResult]:
""" """Run inline picker."""
Run the interactive fuzzy picker. tools = get_tools()
Returns PickerResult with tool name and arguments, or None if cancelled.
"""
tools = get_tools_with_info()
if not tools: if not tools:
print("No tools found. Create one with: cmdforge") print("No tools. Create one: cmdforge")
return None return None
return curses.wrapper(lambda stdscr: _picker_loop(stdscr, tools))
def _picker_loop(stdscr, tools: List[dict]) -> Optional[PickerResult]:
"""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 = "" query = ""
selected_idx = 0 selected = 0
scroll_offset = 0 last_drawn = 0
print(HIDE_CURSOR, end='', flush=True)
try:
while True: while True:
stdscr.clear() # Filter
height, width = stdscr.getmaxyx() matches = []
for t in tools:
ok, score = fuzzy_match(query, t["name"])
if not ok:
ok, score = fuzzy_match(query, t["desc"])
score = score // 2
if ok:
matches.append((t, score))
matches.sort(key=lambda x: -x[1])
filtered = [m[0] for m in matches]
# Filter tools based on query if selected >= len(filtered):
filtered = [] selected = max(0, len(filtered) - 1)
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: # Clear previous
score = max(score_name * 2, score_desc) # Prioritize name matches if last_drawn:
filtered.append((tool, score)) clear_dropdown(last_drawn)
# Sort by score (highest first) # Draw
filtered.sort(key=lambda x: x[1], reverse=True) visible = filtered[:MAX_VISIBLE]
filtered_tools = [t[0] for t in filtered] lines = []
# Adjust selection if out of bounds # Query line
if selected_idx >= len(filtered_tools): prompt = f"{DIM}>{RESET} {YELLOW}{query}{RESET}{'' if True else ''}"
selected_idx = max(0, len(filtered_tools) - 1) lines.append(prompt)
# Calculate visible area (leave room for header and footer) # Items
list_height = height - 4 for i, t in enumerate(visible):
if i == selected:
# Adjust scroll offset prefix = f"{CYAN}{BOLD}{t['name']}{RESET}"
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 ""
has_args = bool(tool["arguments"])
# 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: else:
prefix = " " prefix = f" {t['name']}"
try: # Add arg indicator
stdscr.addstr(y, 0, prefix) if t['args']:
stdscr.addstr(y, 2, name) prefix += f" {GREEN}{RESET}"
if is_selected: # Add description
stdscr.attroff(curses.color_pair(1) | curses.A_BOLD) if t['desc']:
prefix += f" {DIM}- {t['desc']}{RESET}"
# Show argument indicator lines.append(prefix)
if has_args:
stdscr.attron(curses.color_pair(3))
stdscr.addstr(y, 2 + len(name) + 1, "")
stdscr.attroff(curses.color_pair(3))
# Add description in gray # Show count if more
if desc: if len(filtered) > MAX_VISIBLE:
desc_x = 2 + len(name) + (3 if has_args else 2) lines.append(f"{DIM} ... +{len(filtered) - MAX_VISIBLE} more{RESET}")
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 - different if selected tool has args # Print
if filtered_tools and filtered_tools[selected_idx]["arguments"]: sys.stdout.write('\n'.join(lines) + '\n')
footer = " Enter:run Tab:set arguments ↑↓:navigate Esc:cancel " sys.stdout.flush()
else: last_drawn = len(lines)
footer = " Enter:run ↑↓:navigate Esc:cancel "
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 # Input
try: ch = getch()
stdscr.move(1, len(prompt) + len(query))
except curses.error:
pass
stdscr.refresh() if ch in ('\r', '\n'): # Enter - run
if filtered:
clear_dropdown(last_drawn)
print(SHOW_CURSOR, end='', flush=True)
return PickerResult(filtered[selected]["name"], {})
# Handle input elif ch == '\t': # Tab - args or run
try: if filtered:
key = stdscr.getch() tool = filtered[selected]
except KeyboardInterrupt: clear_dropdown(last_drawn)
return None if tool['args']:
args = pick_args(tool)
if key == 27: # Escape print(SHOW_CURSOR, end='', flush=True)
return None return PickerResult(tool["name"], args) if args is not None else None
elif key in (curses.KEY_ENTER, 10, 13): # Enter - run immediately print(SHOW_CURSOR, end='', flush=True)
if filtered_tools:
return PickerResult(filtered_tools[selected_idx]["name"], {})
elif key == ord('\t'): # Tab - open argument picker
if filtered_tools:
tool = filtered_tools[selected_idx]
if tool["arguments"]:
# Show argument picker
args = _argument_picker(stdscr, tool)
if args is not None: # None means cancelled
return PickerResult(tool["name"], args)
# If cancelled, stay in main picker
else:
# No arguments, just run
return PickerResult(tool["name"], {}) return PickerResult(tool["name"], {})
elif key == curses.KEY_UP:
selected_idx = max(0, selected_idx - 1) elif ch == '\x1b' or ch == '\x03': # Esc or Ctrl+C
elif key == curses.KEY_DOWN: clear_dropdown(last_drawn)
selected_idx = min(len(filtered_tools) - 1, selected_idx + 1) print(SHOW_CURSOR, end='', flush=True)
elif key == curses.KEY_BACKSPACE or key == 127: return None
elif ch == 'UP':
selected = max(0, selected - 1)
elif ch == 'DOWN':
selected = min(len(filtered) - 1, selected + 1)
elif ch == '\x7f' or ch == '\b': # Backspace
query = query[:-1] query = query[:-1]
selected_idx = 0 selected = 0
scroll_offset = 0
elif 32 <= key <= 126: # Printable characters elif ch.isprintable():
query += chr(key) query += ch
selected_idx = 0 selected = 0
scroll_offset = 0
except Exception as e:
print(SHOW_CURSOR, end='', flush=True)
raise
def _argument_picker(stdscr, tool: dict) -> Optional[dict]: def pick_args(tool: dict) -> Optional[dict]:
""" """Inline argument picker."""
Show argument picker/editor for a tool. args = tool['args']
Returns dict of flag->value, or None if cancelled. values = {a['flag']: a['default'] for a in args}
""" selected = 0
args = tool["arguments"] editing = None
if not args: edit_buf = ""
return {} last_drawn = 0
selected_idx = 0 print(HIDE_CURSOR, end='', flush=True)
values = {arg["flag"]: arg["default"] for arg in args}
editing = False
edit_buffer = ""
try:
while True: while True:
stdscr.clear() if last_drawn:
height, width = stdscr.getmaxyx() clear_dropdown(last_drawn)
# Header lines = []
stdscr.attron(curses.A_BOLD) lines.append(f"{BOLD}{tool['name']}{RESET} arguments:")
stdscr.addstr(0, 0, f" {tool['name']} - Arguments ")
stdscr.attroff(curses.A_BOLD)
stdscr.addstr(1, 0, "" * min(width-1, 60))
# Draw arguments
for i, arg in enumerate(args): for i, arg in enumerate(args):
y = 2 + i flag = arg['flag']
if y >= height - 2: val = edit_buf if editing == i else values[flag]
break
is_selected = i == selected_idx if i == selected:
flag = arg["flag"] if editing == i:
value = values.get(flag, "") line = f"{CYAN}{flag}: {YELLOW}{val}{RESET}"
desc = arg["description"]
if is_selected:
stdscr.attron(curses.color_pair(1) | curses.A_BOLD)
prefix = ""
else: else:
prefix = " " line = f"{CYAN}{BOLD}{flag}:{RESET} {val or f'{DIM}(empty){RESET}'}"
try:
stdscr.addstr(y, 0, prefix)
stdscr.addstr(y, 2, f"{flag}: ")
if is_selected:
stdscr.attroff(curses.color_pair(1) | curses.A_BOLD)
# Show value or edit buffer
val_x = 2 + len(flag) + 2
if is_selected and editing:
stdscr.attron(curses.color_pair(2))
stdscr.addstr(y, val_x, edit_buffer + "")
stdscr.attroff(curses.color_pair(2))
else: else:
display_val = value if value else "(empty)" line = f" {flag}: {val or f'{DIM}(empty){RESET}'}"
if not value:
stdscr.attron(curses.A_DIM)
stdscr.addstr(y, val_x, display_val[:width-val_x-1])
if not value:
stdscr.attroff(curses.A_DIM)
# Description if arg['desc'] and editing != i:
if desc and not editing: line += f" {DIM}# {arg['desc'][:30]}{RESET}"
desc_x = val_x + len(value if value else "(empty)") + 2
if desc_x < width - 10:
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
# Footer lines.append(line)
if editing:
footer = " Enter:save Esc:cancel edit " if editing is None:
lines.append(f"{DIM}Enter:edit Tab:run Esc:back{RESET}")
sys.stdout.write('\n'.join(lines) + '\n')
sys.stdout.flush()
last_drawn = len(lines)
ch = getch()
if editing is not None:
if ch in ('\r', '\n'): # Save
values[args[editing]['flag']] = edit_buf
editing = None
edit_buf = ""
elif ch == '\x1b': # Cancel edit
editing = None
edit_buf = ""
elif ch == '\x7f' or ch == '\b':
edit_buf = edit_buf[:-1]
elif ch.isprintable():
edit_buf += ch
else: else:
footer = " Enter:edit value Tab:done Esc:back Del:clear " if ch == '\t': # Done
try: clear_dropdown(last_drawn)
stdscr.attron(curses.A_DIM)
stdscr.addstr(height-1, 0, footer[:width-1])
stdscr.attroff(curses.A_DIM)
except curses.error:
pass
stdscr.refresh()
# Handle input
try:
key = stdscr.getch()
except KeyboardInterrupt:
return None
if editing:
if key == 27: # Escape - cancel edit
editing = False
edit_buffer = ""
elif key in (curses.KEY_ENTER, 10, 13): # Enter - save value
values[args[selected_idx]["flag"]] = edit_buffer
editing = False
edit_buffer = ""
elif key == curses.KEY_BACKSPACE or key == 127:
edit_buffer = edit_buffer[:-1]
elif 32 <= key <= 126:
edit_buffer += chr(key)
else:
if key == 27: # Escape - go back to tool picker
return None
elif key == ord('\t'): # Tab - done, run with these args
return values return values
elif key in (curses.KEY_ENTER, 10, 13): # Enter - edit current value elif ch in ('\r', '\n'): # Edit
editing = True editing = selected
edit_buffer = values.get(args[selected_idx]["flag"], "") edit_buf = values[args[selected]['flag']]
elif key == curses.KEY_UP: elif ch == '\x1b' or ch == '\x03': # Back
selected_idx = max(0, selected_idx - 1) clear_dropdown(last_drawn)
elif key == curses.KEY_DOWN: return None
selected_idx = min(len(args) - 1, selected_idx + 1) elif ch == 'UP':
elif key in (curses.KEY_DC, 330): # Delete key - clear value selected = max(0, selected - 1)
values[args[selected_idx]["flag"]] = "" elif ch == 'DOWN':
selected = min(len(args) - 1, selected + 1)
except Exception:
print(SHOW_CURSOR, end='', flush=True)
raise
def main(): def main():
"""Main entry point for the cf command.""" """Entry point for cf command."""
# Check if we're in a terminal
if not sys.stdin.isatty(): if not sys.stdin.isatty():
print("Error: cf requires an interactive terminal", file=sys.stderr) print("cf requires a terminal", file=sys.stderr)
sys.exit(1) sys.exit(1)
try:
result = run_picker() result = run_picker()
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if result: if result:
# Build command with arguments # Build command
cmd_parts = [result.tool_name] cmd = [result.tool_name]
for flag, value in result.arguments.items(): for flag, val in result.arguments.items():
if value: # Only add non-empty arguments if val:
cmd_parts.append(flag) cmd.extend([flag, val])
cmd_parts.append(value)
# Print the command so user can see what was selected # Show what we're running
cmd_str = " ".join(cmd_parts) print(f"{BOLD}{' '.join(cmd)}{RESET}", file=sys.stderr)
print(f"\033[1m{cmd_str}\033[0m", file=sys.stderr)
# Run the tool # Run it
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/{result.tool_name}") tool_path = os.path.expanduser(f"~/.local/bin/{result.tool_name}")
if os.path.exists(tool_path): if os.path.exists(tool_path):
os.execv(tool_path, cmd_parts) os.execv(tool_path, cmd)
else: else:
# Fall back to running via cmdforge runner os.execlp("python", "python", "-m", "cmdforge.runner", *cmd)
os.execlp("python", "python", "-m", "cmdforge.runner", *cmd_parts)
except Exception as e:
# Fallback: just print the command
print(f"Run: {cmd_str}")
else:
sys.exit(0)
if __name__ == "__main__": if __name__ == "__main__":