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 run_picker() -> Optional[PickerResult]: def getch():
""" """Read a single character from stdin."""
Run the interactive fuzzy picker. fd = sys.stdin.fileno()
Returns PickerResult with tool name and arguments, or None if cancelled. old = termios.tcgetattr(fd)
""" try:
tools = get_tools_with_info() 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: 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
while True: print(HIDE_CURSOR, end='', flush=True)
stdscr.clear()
height, width = stdscr.getmaxyx()
# Filter tools based on query try:
filtered = [] while True:
for tool in tools: # Filter
# Match against name and description matches = []
match_name, score_name = fuzzy_match(query, tool["name"]) for t in tools:
match_desc, score_desc = fuzzy_match(query, tool["description"]) 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: if selected >= len(filtered):
score = max(score_name * 2, score_desc) # Prioritize name matches selected = max(0, len(filtered) - 1)
filtered.append((tool, score))
# Sort by score (highest first) # Clear previous
filtered.sort(key=lambda x: x[1], reverse=True) if last_drawn:
filtered_tools = [t[0] for t in filtered] clear_dropdown(last_drawn)
# Adjust selection if out of bounds # Draw
if selected_idx >= len(filtered_tools): visible = filtered[:MAX_VISIBLE]
selected_idx = max(0, len(filtered_tools) - 1) lines = []
# Calculate visible area (leave room for header and footer) # Query line
list_height = height - 4 prompt = f"{DIM}>{RESET} {YELLOW}{query}{RESET}{'' if True else ''}"
lines.append(prompt)
# Adjust scroll offset # Items
if selected_idx < scroll_offset: for i, t in enumerate(visible):
scroll_offset = selected_idx if i == selected:
elif selected_idx >= scroll_offset + list_height: prefix = f"{CYAN}{BOLD}{t['name']}{RESET}"
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
else: 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"], {}) 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
elif ch == '\x1b' or ch == '\x03': # Esc or Ctrl+C
def _argument_picker(stdscr, tool: dict) -> Optional[dict]: clear_dropdown(last_drawn)
""" print(SHOW_CURSOR, end='', flush=True)
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
return None return None
elif key == ord('\t'): # Tab - done, run with these args
return values elif ch == 'UP':
elif key in (curses.KEY_ENTER, 10, 13): # Enter - edit current value selected = max(0, selected - 1)
editing = True elif ch == 'DOWN':
edit_buffer = values.get(args[selected_idx]["flag"], "") selected = min(len(filtered) - 1, selected + 1)
elif key == curses.KEY_UP:
selected_idx = max(0, selected_idx - 1) elif ch == '\x7f' or ch == '\b': # Backspace
elif key == curses.KEY_DOWN: query = query[:-1]
selected_idx = min(len(args) - 1, selected_idx + 1) selected = 0
elif key in (curses.KEY_DC, 330): # Delete key - clear value
values[args[selected_idx]["flag"]] = "" 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(): 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: tool_path = os.path.expanduser(f"~/.local/bin/{result.tool_name}")
# Use exec to replace this process with the tool if os.path.exists(tool_path):
# This way the tool can properly interact with the terminal os.execv(tool_path, cmd)
tool_path = os.path.expanduser(f"~/.local/bin/{result.tool_name}") else:
if os.path.exists(tool_path): os.execlp("python", "python", "-m", "cmdforge.runner", *cmd)
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)
if __name__ == "__main__": if __name__ == "__main__":