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:
parent
7f0dabd328
commit
992c473939
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue