"""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