"""Interactive fuzzy tool picker for CmdForge - inline dropdown style.""" import sys import os import tty import termios from typing import List, Tuple, Optional from dataclasses import dataclass from ..tool import list_tools, load_tool @dataclass class PickerResult: """Result from the picker.""" tool_name: str 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]: """Fuzzy match with scoring.""" if not query: return True, 0 query = query.lower() text = text.lower() if query in text: if text.startswith(query): return True, 1000 + len(query) return True, 500 + len(query) # Character-by-character fuzzy match qi = 0 score = 0 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 return (qi == len(query), score) if qi == len(query) else (False, 0) def get_tools() -> List[dict]: """Get all tools with info.""" tools = [] for name in list_tools(): tool = load_tool(name) if tool: tools.append({ "name": name, "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 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. Create one: cmdforge") return None query = "" selected = 0 last_drawn = 0 print(HIDE_CURSOR, end='', flush=True) 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 selected >= len(filtered): selected = max(0, len(filtered) - 1) # Clear previous if last_drawn: clear_dropdown(last_drawn) # Draw visible = filtered[:MAX_VISIBLE] lines = [] # Query line prompt = f"{DIM}>{RESET} {YELLOW}{query}{RESET}{'▌' if True else ''}" lines.append(prompt) # Items for i, t in enumerate(visible): if i == selected: prefix = f"{CYAN}{BOLD}▸ {t['name']}{RESET}" else: 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 ch == '\x1b' or ch == '\x03': # Esc or Ctrl+C clear_dropdown(last_drawn) print(SHOW_CURSOR, end='', flush=True) 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] 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(): """Entry point for cf command.""" if not sys.stdin.isatty(): print("cf requires a terminal", file=sys.stderr) sys.exit(1) result = run_picker() if result: # Build command cmd = [result.tool_name] for flag, val in result.arguments.items(): if val: cmd.extend([flag, val]) # Show what we're running print(f"{BOLD}{' '.join(cmd)}{RESET}", file=sys.stderr) # 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__": main()