feat: add interactive skill selector
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
"""Terminal multi-select UI for skills."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Iterable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillChoice:
|
||||
name: str
|
||||
kind: str
|
||||
description: str = ""
|
||||
installed: bool = False
|
||||
|
||||
|
||||
def filter_choices(choices: Iterable[SkillChoice], query: str) -> list[SkillChoice]:
|
||||
needle = query.casefold().strip()
|
||||
if not needle:
|
||||
return list(choices)
|
||||
return [
|
||||
choice
|
||||
for choice in choices
|
||||
if needle in choice.name.casefold()
|
||||
or needle in choice.kind.casefold()
|
||||
or needle in choice.description.casefold()
|
||||
]
|
||||
|
||||
|
||||
def select_skills(
|
||||
choices: list[SkillChoice],
|
||||
*,
|
||||
wrapper: Callable[..., set[str] | None] | None = None,
|
||||
) -> set[str] | None:
|
||||
"""Open the selector. Return names, or None when cancelled."""
|
||||
if not choices:
|
||||
return set()
|
||||
|
||||
try:
|
||||
import curses
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("当前 Python 环境不支持 curses,无法打开交互界面") from exc
|
||||
|
||||
selected = {choice.name for choice in choices if choice.installed}
|
||||
|
||||
def draw(stdscr: object) -> set[str] | None:
|
||||
curses.curs_set(0)
|
||||
stdscr.keypad(True)
|
||||
current = 0
|
||||
query = ""
|
||||
|
||||
while True:
|
||||
visible = filter_choices(choices, query)
|
||||
current = min(current, max(0, len(visible) - 1))
|
||||
stdscr.erase()
|
||||
height, width = stdscr.getmaxyx()
|
||||
header = "↑/↓ 移动 Space 勾选 / 搜索 Enter 安装 q 取消"
|
||||
stdscr.addnstr(0, 0, header, max(0, width - 1))
|
||||
if query:
|
||||
stdscr.addnstr(1, 0, f"搜索: {query}", max(0, width - 1))
|
||||
|
||||
rows = max(1, height - 4)
|
||||
start = max(0, current - rows + 1)
|
||||
for row, choice in enumerate(visible[start : start + rows], start=2):
|
||||
index = start + row - 2
|
||||
mark = "x" if choice.name in selected else " "
|
||||
suffix = f" · {choice.description}" if choice.description else ""
|
||||
line = f"[{mark}] {choice.name} {choice.kind}{suffix}"
|
||||
attr = curses.A_REVERSE if index == current else curses.A_NORMAL
|
||||
stdscr.addnstr(row, 0, line, max(0, width - 1), attr)
|
||||
|
||||
footer = f"已选择 {len(selected)} 项"
|
||||
stdscr.addnstr(height - 1, 0, footer, max(0, width - 1))
|
||||
stdscr.refresh()
|
||||
key = stdscr.get_wch()
|
||||
|
||||
if key in ("q", "Q", "\x1b"):
|
||||
return None
|
||||
if key in ("\n", "\r") or key == curses.KEY_ENTER:
|
||||
return set(selected)
|
||||
if key == curses.KEY_UP and visible:
|
||||
current = (current - 1) % len(visible)
|
||||
elif key == curses.KEY_DOWN and visible:
|
||||
current = (current + 1) % len(visible)
|
||||
elif key == " " and visible:
|
||||
name = visible[current].name
|
||||
selected.symmetric_difference_update({name})
|
||||
elif key == "/":
|
||||
curses.curs_set(1)
|
||||
query = _read_query(stdscr, curses, width)
|
||||
curses.curs_set(0)
|
||||
current = 0
|
||||
|
||||
runner = wrapper or curses.wrapper
|
||||
return runner(draw)
|
||||
|
||||
|
||||
def _read_query(stdscr: object, curses: object, width: int) -> str:
|
||||
query = ""
|
||||
while True:
|
||||
stdscr.move(1, 0)
|
||||
stdscr.clrtoeol()
|
||||
stdscr.addnstr(1, 0, f"搜索: {query}", max(0, width - 1))
|
||||
stdscr.refresh()
|
||||
key = stdscr.get_wch()
|
||||
if key in ("\n", "\r") or key == curses.KEY_ENTER:
|
||||
return query
|
||||
if key == "\x1b":
|
||||
return ""
|
||||
if key in ("\b", "\x7f") or key == curses.KEY_BACKSPACE:
|
||||
query = query[:-1]
|
||||
elif isinstance(key, str) and key.isprintable():
|
||||
query += key
|
||||
Reference in New Issue
Block a user