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 os
import subprocess
import tty
import termios
from typing import List, Tuple, Optional
from dataclasses import dataclass
@ -14,416 +14,307 @@ from ..tool import list_tools, load_tool
class PickerResult:
"""Result from the picker."""
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]:
"""
Check if query fuzzy-matches text.
Returns (matches, score) where higher score = better match.
"""
"""Fuzzy match with scoring."""
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
# Character-by-character fuzzy match
qi = 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):
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
return (qi == len(query), score) if qi == len(query) else (False, 0)
def get_tools_with_info() -> List[dict]:
"""Get all tools with their info for display."""
def get_tools() -> List[dict]:
"""Get all tools with info."""
tools = []
for name in list_tools():
tool = load_tool(name)
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({
"name": name,
"description": tool.description or "",
"category": tool.category or "Other",
"arguments": args,
"desc": (tool.description or "")[:50],
"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"])
def run_picker() -> Optional[PickerResult]:
"""
Run the interactive fuzzy picker.
Returns PickerResult with tool name and arguments, or None if cancelled.
"""
tools = get_tools_with_info()
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]:
"""Run inline picker."""
tools = get_tools()
if not tools:
print("No tools found. Create one with: cmdforge")
print("No tools. Create one: cmdforge")
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 = ""
selected_idx = 0
scroll_offset = 0
selected = 0
last_drawn = 0
while True:
stdscr.clear()
height, width = stdscr.getmaxyx()
print(HIDE_CURSOR, end='', flush=True)
# 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"])
try:
while True:
# Filter
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]
if match_name or match_desc:
score = max(score_name * 2, score_desc) # Prioritize name matches
filtered.append((tool, score))
if selected >= len(filtered):
selected = max(0, len(filtered) - 1)
# Sort by score (highest first)
filtered.sort(key=lambda x: x[1], reverse=True)
filtered_tools = [t[0] for t in filtered]
# Clear previous
if last_drawn:
clear_dropdown(last_drawn)
# Adjust selection if out of bounds
if selected_idx >= len(filtered_tools):
selected_idx = max(0, len(filtered_tools) - 1)
# Draw
visible = filtered[:MAX_VISIBLE]
lines = []
# Calculate visible area (leave room for header and footer)
list_height = height - 4
# Query line
prompt = f"{DIM}>{RESET} {YELLOW}{query}{RESET}{'' if True else ''}"
lines.append(prompt)
# 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 ""
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:
prefix = " "
try:
stdscr.addstr(y, 0, prefix)
stdscr.addstr(y, 2, name)
if is_selected:
stdscr.attroff(curses.color_pair(1) | curses.A_BOLD)
# Show argument indicator
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
if desc:
desc_x = 2 + len(name) + (3 if has_args else 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 - different if selected tool has args
if filtered_tools and filtered_tools[selected_idx]["arguments"]:
footer = " Enter:run Tab:set arguments ↑↓:navigate Esc:cancel "
else:
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
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): # Enter - run immediately
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
# Items
for i, t in enumerate(visible):
if i == selected:
prefix = f"{CYAN}{BOLD}{t['name']}{RESET}"
else:
# No arguments, just run
prefix = f" {t['name']}"
# Add arg indicator
if t['args']:
prefix += f" {GREEN}{RESET}"
# Add description
if t['desc']:
prefix += f" {DIM}- {t['desc']}{RESET}"
lines.append(prefix)
# Show count if more
if len(filtered) > MAX_VISIBLE:
lines.append(f"{DIM} ... +{len(filtered) - MAX_VISIBLE} more{RESET}")
# Print
sys.stdout.write('\n'.join(lines) + '\n')
sys.stdout.flush()
last_drawn = len(lines)
# Input
ch = getch()
if ch in ('\r', '\n'): # Enter - run
if filtered:
clear_dropdown(last_drawn)
print(SHOW_CURSOR, end='', flush=True)
return PickerResult(filtered[selected]["name"], {})
elif ch == '\t': # Tab - args or run
if filtered:
tool = filtered[selected]
clear_dropdown(last_drawn)
if tool['args']:
args = pick_args(tool)
print(SHOW_CURSOR, end='', flush=True)
return PickerResult(tool["name"], args) if args is not None else None
print(SHOW_CURSOR, end='', flush=True)
return PickerResult(tool["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 32 <= key <= 126: # Printable characters
query += chr(key)
selected_idx = 0
scroll_offset = 0
def _argument_picker(stdscr, tool: dict) -> Optional[dict]:
"""
Show argument picker/editor for a tool.
Returns dict of flag->value, or None if cancelled.
"""
args = tool["arguments"]
if not args:
return {}
selected_idx = 0
values = {arg["flag"]: arg["default"] for arg in args}
editing = False
edit_buffer = ""
while True:
stdscr.clear()
height, width = stdscr.getmaxyx()
# Header
stdscr.attron(curses.A_BOLD)
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):
y = 2 + i
if y >= height - 2:
break
is_selected = i == selected_idx
flag = arg["flag"]
value = values.get(flag, "")
desc = arg["description"]
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, 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:
display_val = value if value else "(empty)"
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 desc and not editing:
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
if editing:
footer = " Enter:save Esc:cancel edit "
else:
footer = " Enter:edit value Tab:done Esc:back Del:clear "
try:
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
elif ch == '\x1b' or ch == '\x03': # Esc or Ctrl+C
clear_dropdown(last_drawn)
print(SHOW_CURSOR, end='', flush=True)
return None
elif key == ord('\t'): # Tab - done, run with these args
return values
elif key in (curses.KEY_ENTER, 10, 13): # Enter - edit current value
editing = True
edit_buffer = values.get(args[selected_idx]["flag"], "")
elif key == curses.KEY_UP:
selected_idx = max(0, selected_idx - 1)
elif key == curses.KEY_DOWN:
selected_idx = min(len(args) - 1, selected_idx + 1)
elif key in (curses.KEY_DC, 330): # Delete key - clear value
values[args[selected_idx]["flag"]] = ""
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]
selected = 0
elif ch.isprintable():
query += ch
selected = 0
except Exception as e:
print(SHOW_CURSOR, end='', flush=True)
raise
def pick_args(tool: dict) -> Optional[dict]:
"""Inline argument picker."""
args = tool['args']
values = {a['flag']: a['default'] for a in args}
selected = 0
editing = None
edit_buf = ""
last_drawn = 0
print(HIDE_CURSOR, end='', flush=True)
try:
while True:
if last_drawn:
clear_dropdown(last_drawn)
lines = []
lines.append(f"{BOLD}{tool['name']}{RESET} arguments:")
for i, arg in enumerate(args):
flag = arg['flag']
val = edit_buf if editing == i else values[flag]
if i == selected:
if editing == i:
line = f"{CYAN}{flag}: {YELLOW}{val}{RESET}"
else:
line = f"{CYAN}{BOLD}{flag}:{RESET} {val or f'{DIM}(empty){RESET}'}"
else:
line = f" {flag}: {val or f'{DIM}(empty){RESET}'}"
if arg['desc'] and editing != i:
line += f" {DIM}# {arg['desc'][:30]}{RESET}"
lines.append(line)
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:
if ch == '\t': # Done
clear_dropdown(last_drawn)
return values
elif ch in ('\r', '\n'): # Edit
editing = selected
edit_buf = values[args[selected]['flag']]
elif ch == '\x1b' or ch == '\x03': # Back
clear_dropdown(last_drawn)
return None
elif ch == 'UP':
selected = max(0, selected - 1)
elif ch == 'DOWN':
selected = min(len(args) - 1, selected + 1)
except Exception:
print(SHOW_CURSOR, end='', flush=True)
raise
def main():
"""Main entry point for the cf command."""
# Check if we're in a terminal
"""Entry point for cf command."""
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)
try:
result = run_picker()
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
result = run_picker()
if result:
# Build command with arguments
cmd_parts = [result.tool_name]
for flag, value in result.arguments.items():
if value: # Only add non-empty arguments
cmd_parts.append(flag)
cmd_parts.append(value)
# Build command
cmd = [result.tool_name]
for flag, val in result.arguments.items():
if val:
cmd.extend([flag, val])
# Print the command so user can see what was selected
cmd_str = " ".join(cmd_parts)
print(f"\033[1m{cmd_str}\033[0m", file=sys.stderr)
# Show what we're running
print(f"{BOLD}{' '.join(cmd)}{RESET}", file=sys.stderr)
# Run the tool
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}")
if os.path.exists(tool_path):
os.execv(tool_path, cmd_parts)
else:
# Fall back to running via cmdforge runner
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)
# Run it
tool_path = os.path.expanduser(f"~/.local/bin/{result.tool_name}")
if os.path.exists(tool_path):
os.execv(tool_path, cmd)
else:
os.execlp("python", "python", "-m", "cmdforge.runner", *cmd)
if __name__ == "__main__":