From df1d3ace2393a0bff2709e80e556b2a3111cfc36 Mon Sep 17 00:00:00 2001 From: rob Date: Sun, 18 Jan 2026 02:46:04 -0400 Subject: [PATCH] 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 --- src/cmdforge/cli/picker.py | 609 +++++++++++++++---------------------- 1 file changed, 250 insertions(+), 359 deletions(-) diff --git a/src/cmdforge/cli/picker.py b/src/cmdforge/cli/picker.py index 99f826b..1e52f06 100644 --- a/src/cmdforge/cli/picker.py +++ b/src/cmdforge/cli/picker.py @@ -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__":