feat: rename skills/skiff to pouch and move ACK state under .pouch
Use ~/.pouch, the pouch CLI, and .pouch.yaml as the SSOT container. Keep the inner skills/ packages, and store ACK project state in .pouch/ack instead of docs/ack.
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
"""Terminal multi-select UI for skills."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Iterable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillChoice:
|
||||
name: str
|
||||
kind: str
|
||||
description: str = ""
|
||||
installed: bool = False
|
||||
readonly_status: str = ""
|
||||
children: tuple[str, ...] = ()
|
||||
indent: int = 0
|
||||
|
||||
|
||||
def fit_to_width(text: str, width: int) -> str:
|
||||
"""Trim text to terminal display width without splitting wide characters."""
|
||||
if width <= 0:
|
||||
return ""
|
||||
result: list[str] = []
|
||||
used = 0
|
||||
for char in text:
|
||||
if unicodedata.combining(char):
|
||||
char_width = 0
|
||||
else:
|
||||
char_width = 2 if unicodedata.east_asian_width(char) in {"W", "F"} else 1
|
||||
if used + char_width > width:
|
||||
break
|
||||
result.append(char)
|
||||
used += char_width
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def display_width(text: str) -> int:
|
||||
return sum(
|
||||
0
|
||||
if unicodedata.combining(char)
|
||||
else 2
|
||||
if unicodedata.east_asian_width(char) in {"W", "F"}
|
||||
else 1
|
||||
for char in text
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
or needle in choice.readonly_status.casefold()
|
||||
]
|
||||
|
||||
|
||||
def select_skills(
|
||||
choices: list[SkillChoice],
|
||||
*,
|
||||
scope_label: str = "当前作用域",
|
||||
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 and not choice.children
|
||||
}
|
||||
|
||||
def mark_for(choice: SkillChoice) -> str:
|
||||
if not choice.children:
|
||||
return "x" if choice.name in selected else " "
|
||||
selected_children = selected.intersection(choice.children)
|
||||
if len(selected_children) == len(choice.children):
|
||||
return "x"
|
||||
return "-" if selected_children else " "
|
||||
|
||||
def draw(stdscr: object) -> set[str] | None:
|
||||
curses.curs_set(0)
|
||||
stdscr.keypad(True)
|
||||
current = 0
|
||||
query = ""
|
||||
source_attr = curses.A_NORMAL
|
||||
status_attr = curses.A_NORMAL
|
||||
description_attr = curses.A_DIM
|
||||
try:
|
||||
if curses.has_colors():
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(2, curses.COLOR_GREEN, -1)
|
||||
source_attr = curses.color_pair(1)
|
||||
status_attr = curses.color_pair(2)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
while True:
|
||||
visible = filter_choices(choices, query)
|
||||
current = min(current, max(0, len(visible) - 1))
|
||||
stdscr.erase()
|
||||
height, width = stdscr.getmaxyx()
|
||||
text_width = max(0, width - 1)
|
||||
stdscr.addnstr(
|
||||
0,
|
||||
0,
|
||||
fit_to_width(f"安装目标: {scope_label}", text_width),
|
||||
text_width,
|
||||
)
|
||||
header = "↑↓ 移动 Space 选择 / 搜索 Enter 安装 q 退出"
|
||||
stdscr.addnstr(1, 0, fit_to_width(header, text_width), text_width)
|
||||
if query:
|
||||
stdscr.addnstr(
|
||||
2,
|
||||
0,
|
||||
fit_to_width(f"搜索: {query}", text_width),
|
||||
text_width,
|
||||
)
|
||||
|
||||
item_capacity = max(1, (height - 4) // 3)
|
||||
start = max(0, current - item_capacity + 1)
|
||||
for offset, choice in enumerate(visible[start : start + item_capacity]):
|
||||
index = start + offset
|
||||
row = 3 + offset * 3
|
||||
mark = mark_for(choice)
|
||||
description = f" {choice.description}" if choice.description else ""
|
||||
focus_attr = curses.A_REVERSE if index == current else curses.A_NORMAL
|
||||
parts = [
|
||||
(f"{' ' * choice.indent}[{mark}] ", focus_attr),
|
||||
(choice.name, focus_attr | curses.A_BOLD),
|
||||
(f" {choice.kind}", focus_attr | source_attr),
|
||||
]
|
||||
if choice.readonly_status:
|
||||
parts.append(
|
||||
(f" {choice.readonly_status}", focus_attr | status_attr)
|
||||
)
|
||||
column = 0
|
||||
for text, attr in parts:
|
||||
available = text_width - column
|
||||
if available <= 0:
|
||||
break
|
||||
fitted = fit_to_width(text, available)
|
||||
stdscr.addnstr(row, column, fitted, available, attr)
|
||||
column += display_width(fitted)
|
||||
stdscr.addnstr(
|
||||
row + 1,
|
||||
0,
|
||||
fit_to_width(description, text_width),
|
||||
text_width,
|
||||
focus_attr | description_attr,
|
||||
)
|
||||
|
||||
footer = (
|
||||
f"已选择 {len(selected)} 项 · 取消选择不卸载,卸载用 pouch remove"
|
||||
)
|
||||
stdscr.addnstr(
|
||||
height - 1,
|
||||
0,
|
||||
fit_to_width(footer, text_width),
|
||||
text_width,
|
||||
)
|
||||
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:
|
||||
choice = visible[current]
|
||||
if choice.children:
|
||||
children = set(choice.children)
|
||||
if children.issubset(selected):
|
||||
selected.difference_update(children)
|
||||
else:
|
||||
selected.update(children)
|
||||
else:
|
||||
selected.symmetric_difference_update({choice.name})
|
||||
elif key == "/":
|
||||
curses.curs_set(1)
|
||||
query = _read_query(stdscr, curses, width, row=2)
|
||||
curses.curs_set(0)
|
||||
current = 0
|
||||
|
||||
runner = wrapper or curses.wrapper
|
||||
return runner(draw)
|
||||
|
||||
|
||||
def _read_query(stdscr: object, curses: object, width: int, *, row: int = 1) -> str:
|
||||
query = ""
|
||||
while True:
|
||||
stdscr.move(row, 0)
|
||||
stdscr.clrtoeol()
|
||||
text_width = max(0, width - 1)
|
||||
stdscr.addnstr(
|
||||
row,
|
||||
0,
|
||||
fit_to_width(f"搜索: {query}", text_width),
|
||||
text_width,
|
||||
)
|
||||
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