feat: add self-update and improve skill selector

This commit is contained in:
2026-07-29 10:29:48 +08:00
parent afbdde157f
commit 18ebb70911
9 changed files with 431 additions and 47 deletions
+111 -17
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import unicodedata
from dataclasses import dataclass
from typing import Callable, Iterable
@@ -12,6 +13,36 @@ class SkillChoice:
kind: str
description: str = ""
installed: bool = False
readonly_status: str = ""
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]:
@@ -24,12 +55,14 @@ def filter_choices(choices: Iterable[SkillChoice], query: str) -> list[SkillChoi
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."""
@@ -48,29 +81,84 @@ def select_skills(
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()
header = "↑/↓ 移动 Space 勾选 / 搜索 Enter 安装 q 取消"
stdscr.addnstr(0, 0, header, max(0, width - 1))
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(1, 0, f"搜索: {query}", max(0, width - 1))
stdscr.addnstr(
2,
0,
fit_to_width(f"搜索: {query}", text_width),
text_width,
)
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
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 = "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)
description = f" {choice.description}" if choice.description else ""
focus_attr = curses.A_REVERSE if index == current else curses.A_NORMAL
parts = [
(f"[{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)}"
stdscr.addnstr(height - 1, 0, footer, max(0, width - 1))
footer = (
f"已选择 {len(selected)} 项 · 取消选择不卸载,卸载用 skiff remove"
)
stdscr.addnstr(
height - 1,
0,
fit_to_width(footer, text_width),
text_width,
)
stdscr.refresh()
key = stdscr.get_wch()
@@ -87,7 +175,7 @@ def select_skills(
selected.symmetric_difference_update({name})
elif key == "/":
curses.curs_set(1)
query = _read_query(stdscr, curses, width)
query = _read_query(stdscr, curses, width, row=2)
curses.curs_set(0)
current = 0
@@ -95,12 +183,18 @@ def select_skills(
return runner(draw)
def _read_query(stdscr: object, curses: object, width: int) -> str:
def _read_query(stdscr: object, curses: object, width: int, *, row: int = 1) -> str:
query = ""
while True:
stdscr.move(1, 0)
stdscr.move(row, 0)
stdscr.clrtoeol()
stdscr.addnstr(1, 0, f"搜索: {query}", max(0, width - 1))
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: