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 os
import subprocess import subprocess
from typing import List, Tuple, Optional from typing import List, Tuple, Optional
from dataclasses import dataclass
from ..tool import list_tools, load_tool 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]: def fuzzy_match(query: str, text: str) -> Tuple[bool, int]:
""" """
Check if query fuzzy-matches text. Check if query fuzzy-matches text.
@ -54,7 +62,14 @@ def get_tools_with_info() -> List[dict]:
for name in list_tools(): for name in list_tools():
tool = load_tool(name) tool = load_tool(name)
if tool: 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({ tools.append({
"name": name, "name": name,
"description": tool.description or "", "description": tool.description or "",
@ -64,10 +79,10 @@ def get_tools_with_info() -> List[dict]:
return sorted(tools, key=lambda t: t["name"]) return sorted(tools, key=lambda t: t["name"])
def run_picker() -> Optional[str]: def run_picker() -> Optional[PickerResult]:
""" """
Run the interactive fuzzy picker. 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() tools = get_tools_with_info()
@ -78,7 +93,7 @@ def run_picker() -> Optional[str]:
return curses.wrapper(lambda stdscr: _picker_loop(stdscr, tools)) 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.""" """Main picker loop using curses."""
curses.curs_set(1) # Show cursor curses.curs_set(1) # Show cursor
curses.use_default_colors() curses.use_default_colors()
@ -155,6 +170,7 @@ def _picker_loop(stdscr, tools: List[dict]) -> Optional[str]:
# Build display line # Build display line
name = tool["name"] name = tool["name"]
desc = tool["description"][:width-len(name)-5] if tool["description"] else "" desc = tool["description"][:width-len(name)-5] if tool["description"] else ""
has_args = bool(tool["arguments"])
# Truncate if needed # Truncate if needed
max_name_len = min(30, width // 3) max_name_len = min(30, width // 3)
@ -174,9 +190,15 @@ def _picker_loop(stdscr, tools: List[dict]) -> Optional[str]:
if is_selected: if is_selected:
stdscr.attroff(curses.color_pair(1) | curses.A_BOLD) 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 # Add description in gray
if desc: if desc:
desc_x = 2 + len(name) + 2 desc_x = 2 + len(name) + (3 if has_args else 2)
if desc_x < width - 5: if desc_x < width - 5:
stdscr.attron(curses.color_pair(4)) stdscr.attron(curses.color_pair(4))
stdscr.addstr(y, desc_x, f"- {desc}"[:width-desc_x-1]) 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: except curses.error:
pass # Ignore drawing errors at screen edge pass # Ignore drawing errors at screen edge
# Draw footer with help # Draw footer with help - different if selected tool has args
footer = " ↑↓:navigate Tab/Enter:select Esc:cancel ?: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: try:
stdscr.attron(curses.A_DIM) stdscr.attron(curses.A_DIM)
stdscr.addstr(height-1, 0, footer[:width-1]) 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 if key == 27: # Escape
return None 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: 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: elif key == curses.KEY_UP:
selected_idx = max(0, selected_idx - 1) selected_idx = max(0, selected_idx - 1)
elif key == curses.KEY_DOWN: elif key == curses.KEY_DOWN:
@ -220,20 +257,133 @@ def _picker_loop(stdscr, tools: List[dict]) -> Optional[str]:
query = query[:-1] query = query[:-1]
selected_idx = 0 selected_idx = 0
scroll_offset = 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 elif 32 <= key <= 126: # Printable characters
query += chr(key) query += chr(key)
selected_idx = 0 selected_idx = 0
scroll_offset = 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(): def main():
"""Main entry point for the cf command.""" """Main entry point for the cf command."""
# Check if we're in a terminal # Check if we're in a terminal
@ -242,29 +392,36 @@ def main():
sys.exit(1) sys.exit(1)
try: try:
selected = run_picker() result = run_picker()
except Exception as e: except Exception as e:
print(f"Error: {e}", file=sys.stderr) print(f"Error: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
if selected: if result:
# Print the command so user can see what was selected # Build command with arguments
print(f"\033[1m{selected}\033[0m", file=sys.stderr) 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) # Print the command so user can see what was selected
# Run the tool - it will read from stdin/user input cmd_str = " ".join(cmd_parts)
print(f"\033[1m{cmd_str}\033[0m", file=sys.stderr)
# Run the tool
try: try:
# Use exec to replace this process with the tool # Use exec to replace this process with the tool
# This way the tool can properly interact with the terminal # 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): if os.path.exists(tool_path):
os.execv(tool_path, [selected]) os.execv(tool_path, cmd_parts)
else: else:
# Fall back to running via cmdforge runner # 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: except Exception as e:
# Fallback: just print the command # Fallback: just print the command
print(f"Run: {selected}") print(f"Run: {cmd_str}")
else: else:
sys.exit(0) sys.exit(0)