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 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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue