From 992c47393994f4f6903a0302d2308ad2ebb293dd Mon Sep 17 00:00:00 2001 From: rob Date: Sun, 18 Jan 2026 02:39:37 -0400 Subject: [PATCH] Improve cf picker: Enter runs, Tab opens argument editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enter: immediately runs the selected tool - Tab: opens argument picker for tools with arguments - Shows ⚙ indicator for tools that have arguments - Argument editor lets you set values with Enter to edit, Tab to run - Esc in argument picker goes back to tool list - Footer changes based on whether selected tool has args Co-Authored-By: Claude Opus 4.5 --- src/cmdforge/cli/picker.py | 211 ++++++++++++++++++++++++++++++++----- 1 file changed, 184 insertions(+), 27 deletions(-) diff --git a/src/cmdforge/cli/picker.py b/src/cmdforge/cli/picker.py index 8279b09..99f826b 100644 --- a/src/cmdforge/cli/picker.py +++ b/src/cmdforge/cli/picker.py @@ -5,10 +5,18 @@ import sys import os import subprocess 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 # flag -> value + + def fuzzy_match(query: str, text: str) -> Tuple[bool, int]: """ Check if query fuzzy-matches text. @@ -54,7 +62,14 @@ def get_tools_with_info() -> List[dict]: for name in list_tools(): tool = load_tool(name) if tool: - args = [arg.flag for arg in tool.arguments] if tool.arguments else [] + 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 "", @@ -64,10 +79,10 @@ def get_tools_with_info() -> List[dict]: return sorted(tools, key=lambda t: t["name"]) -def run_picker() -> Optional[str]: +def run_picker() -> Optional[PickerResult]: """ Run the interactive fuzzy picker. - Returns the selected tool name, or None if cancelled. + Returns PickerResult with tool name and arguments, or None if cancelled. """ tools = get_tools_with_info() @@ -78,7 +93,7 @@ def run_picker() -> Optional[str]: return curses.wrapper(lambda stdscr: _picker_loop(stdscr, tools)) -def _picker_loop(stdscr, tools: List[dict]) -> Optional[str]: +def _picker_loop(stdscr, tools: List[dict]) -> Optional[PickerResult]: """Main picker loop using curses.""" curses.curs_set(1) # Show cursor curses.use_default_colors() @@ -155,6 +170,7 @@ def _picker_loop(stdscr, tools: List[dict]) -> Optional[str]: # 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) @@ -174,9 +190,15 @@ def _picker_loop(stdscr, tools: List[dict]) -> Optional[str]: 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) + 2 + 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]) @@ -184,8 +206,11 @@ def _picker_loop(stdscr, tools: List[dict]) -> Optional[str]: except curses.error: pass # Ignore drawing errors at screen edge - # Draw footer with help - footer = " ↑↓:navigate Tab/Enter:select Esc:cancel ?:args " + # 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]) @@ -209,9 +234,21 @@ def _picker_loop(stdscr, tools: List[dict]) -> Optional[str]: if key == 27: # Escape return None - elif key in (curses.KEY_ENTER, 10, 13, ord('\t')): # Enter or Tab + elif key in (curses.KEY_ENTER, 10, 13): # Enter - run immediately if filtered_tools: - return filtered_tools[selected_idx]["name"] + 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"], {}) elif key == curses.KEY_UP: selected_idx = max(0, selected_idx - 1) elif key == curses.KEY_DOWN: @@ -220,20 +257,133 @@ def _picker_loop(stdscr, tools: List[dict]) -> Optional[str]: query = query[:-1] selected_idx = 0 scroll_offset = 0 - elif key == ord('?') and filtered_tools: - # Show arguments for selected tool - tool = filtered_tools[selected_idx] - if tool["arguments"]: - args_str = " ".join(tool["arguments"]) - stdscr.addstr(height-1, 0, f" Args: {args_str}"[:width-1] + " " * 20) - stdscr.refresh() - stdscr.getch() # Wait for any key 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 + 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"]] = "" + + def main(): """Main entry point for the cf command.""" # Check if we're in a terminal @@ -242,29 +392,36 @@ def main(): sys.exit(1) try: - selected = run_picker() + result = run_picker() except Exception as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) - if selected: - # Print the command so user can see what was selected - print(f"\033[1m{selected}\033[0m", file=sys.stderr) + 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) - # Check if stdin has data piped (it shouldn't since we're interactive) - # Run the tool - it will read from stdin/user input + # 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) + + # 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/{selected}") + tool_path = os.path.expanduser(f"~/.local/bin/{result.tool_name}") if os.path.exists(tool_path): - os.execv(tool_path, [selected]) + os.execv(tool_path, cmd_parts) else: # Fall back to running via cmdforge runner - os.execlp("python", "python", "-m", "cmdforge.runner", selected) + os.execlp("python", "python", "-m", "cmdforge.runner", *cmd_parts) except Exception as e: # Fallback: just print the command - print(f"Run: {selected}") + print(f"Run: {cmd_str}") else: sys.exit(0)