Improve cf picker: Enter runs, Tab opens argument editor

- 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 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-18 02:39:37 -04:00
parent 7f0dabd328
commit 992c473939
1 changed files with 184 additions and 27 deletions

View File

@ -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)