j
This commit is contained in:
132
cmdnat/help.py
132
cmdnat/help.py
@@ -15,7 +15,10 @@ def _normalize_choice_list(arg_names: Optional[List[str]]) -> List[str]:
|
||||
return sorted(set(arg_names or []))
|
||||
|
||||
|
||||
def _examples_for_cmd(name: str) -> List[str]:
|
||||
_HELP_EXAMPLE_SOURCE_COMMAND = ".help-example"
|
||||
|
||||
|
||||
def _example_for_cmd(name: str) -> List[str]:
|
||||
"""Return example invocations for a given command (best-effort)."""
|
||||
lookup = {
|
||||
".adjective": [
|
||||
@@ -28,6 +31,21 @@ def _examples_for_cmd(name: str) -> List[str]:
|
||||
return lookup.get(key, [])
|
||||
|
||||
|
||||
def _parse_example_tokens(example: str) -> List[str]:
|
||||
"""Split an example string into CLI tokens suitable for @N selection."""
|
||||
|
||||
text = str(example or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
|
||||
try:
|
||||
tokens = shlex.split(text)
|
||||
except Exception:
|
||||
tokens = text.split()
|
||||
|
||||
return [token for token in tokens if token]
|
||||
|
||||
|
||||
def _normalize_cmdlet_key(name: Optional[str]) -> str:
|
||||
return str(name or "").replace("_", "-").lower().strip()
|
||||
|
||||
@@ -103,6 +121,16 @@ def _gather_metadata_from_cmdlet_classes() -> Tuple[Dict[str, Dict[str, Any]], D
|
||||
canonical_key = _normalize_cmdlet_key(getattr(cmdlet_obj, "name", None) or "")
|
||||
if not canonical_key:
|
||||
continue
|
||||
example_entries: List[str] = []
|
||||
seen_example_entries: set[str] = set()
|
||||
for attr in ("examples", "example"):
|
||||
for value in (getattr(cmdlet_obj, attr, []) or []):
|
||||
text = str(value or "").strip()
|
||||
if not text or text in seen_example_entries:
|
||||
continue
|
||||
seen_example_entries.add(text)
|
||||
example_entries.append(text)
|
||||
|
||||
entry = {
|
||||
"name": str(getattr(cmdlet_obj, "name", "") or canonical_key),
|
||||
"summary": str(getattr(cmdlet_obj, "summary", "") or ""),
|
||||
@@ -110,6 +138,7 @@ def _gather_metadata_from_cmdlet_classes() -> Tuple[Dict[str, Dict[str, Any]], D
|
||||
"aliases": _cmdlet_aliases(cmdlet_obj),
|
||||
"details": list(getattr(cmdlet_obj, "detail", []) or []),
|
||||
"args": [_cmdlet_arg_to_dict(a) for a in getattr(cmdlet_obj, "arg", []) or []],
|
||||
"examples": example_entries,
|
||||
"raw": getattr(cmdlet_obj, "raw", None),
|
||||
}
|
||||
metadata[canonical_key] = entry
|
||||
@@ -185,60 +214,66 @@ def _render_list(
|
||||
|
||||
ctx.set_last_result_table(table, items)
|
||||
ctx.set_current_stage_table(table)
|
||||
setattr(table, "_rendered_by_cmdlet", True)
|
||||
from SYS.rich_display import stdout_console
|
||||
|
||||
stdout_console().print(table)
|
||||
|
||||
|
||||
def _render_detail(meta: Dict[str, Any], args: Sequence[str]) -> None:
|
||||
title = f"Help: {meta.get('name', '') or 'cmd'}"
|
||||
table = ResultTable(title)
|
||||
table.set_source_command(".help", list(args))
|
||||
|
||||
header_lines: List[str] = []
|
||||
def _render_detail(meta: Dict[str, Any], _args: Sequence[str]) -> None:
|
||||
cmd_name = str(meta.get("name", "") or "cmd")
|
||||
title = f"Help: {cmd_name}"
|
||||
summary = meta.get("summary", "")
|
||||
usage = meta.get("usage", "")
|
||||
aliases = meta.get("aliases", []) or []
|
||||
examples = _examples_for_cmd(meta.get("name", ""))
|
||||
first_example_tokens: List[str] = []
|
||||
first_example_cmd: Optional[str] = None
|
||||
if examples:
|
||||
try:
|
||||
split_tokens = shlex.split(examples[0])
|
||||
if split_tokens:
|
||||
first_example_cmd = split_tokens[0]
|
||||
first_example_tokens = split_tokens[1:]
|
||||
except Exception:
|
||||
pass
|
||||
details = meta.get("details", []) or []
|
||||
seen_examples: set[str] = set()
|
||||
explicit_example: List[str] = []
|
||||
for attr in ("examples", "example"):
|
||||
for value in (meta.get(attr, []) or []):
|
||||
text = str(value or "").strip()
|
||||
if not text or text in seen_examples:
|
||||
continue
|
||||
seen_examples.add(text)
|
||||
explicit_example.append(text)
|
||||
|
||||
fallback_example = _example_for_cmd(cmd_name)
|
||||
for fallback in fallback_example:
|
||||
text = str(fallback or "").strip()
|
||||
if not text or text in seen_examples:
|
||||
continue
|
||||
seen_examples.add(text)
|
||||
explicit_example.append(text)
|
||||
|
||||
header_lines: List[str] = []
|
||||
if summary:
|
||||
header_lines.append(summary)
|
||||
if usage:
|
||||
header_lines.append(f"Usage: {usage}")
|
||||
if aliases:
|
||||
header_lines.append("Aliases: " + ", ".join(aliases))
|
||||
if examples:
|
||||
header_lines.append("Examples: " + " | ".join(examples))
|
||||
if header_lines:
|
||||
table.set_header_lines(header_lines)
|
||||
if details:
|
||||
header_lines.extend(str(line) for line in details if str(line).strip())
|
||||
if explicit_example:
|
||||
header_lines.append("Examples available below")
|
||||
|
||||
args_meta = meta.get("args", []) or []
|
||||
example_text = " | ".join(examples)
|
||||
# If we have an example, use it as the source command so @N runs that example
|
||||
if first_example_cmd:
|
||||
table.set_source_command(first_example_cmd, [])
|
||||
|
||||
args_table = ResultTable(title)
|
||||
if header_lines:
|
||||
args_table.set_header_lines(header_lines)
|
||||
args_table.set_preserve_order(True)
|
||||
args_table.set_no_choice(True)
|
||||
|
||||
if not args_meta:
|
||||
row = table.add_row()
|
||||
row = args_table.add_row()
|
||||
row.add_column("Arg", "(none)")
|
||||
row.add_column("Type", "")
|
||||
row.add_column("Req", "")
|
||||
row.add_column("Description", "")
|
||||
row.add_column("Example", example_text)
|
||||
if first_example_tokens:
|
||||
table.set_row_selection_args(len(table.rows) - 1, first_example_tokens)
|
||||
else:
|
||||
for arg in args_meta:
|
||||
row = table.add_row()
|
||||
row = args_table.add_row()
|
||||
name = arg.get("name") or ""
|
||||
row.add_column("Arg", f"-{name}" if name else "")
|
||||
row.add_column("Type", arg.get("type", ""))
|
||||
@@ -249,15 +284,38 @@ def _render_detail(meta: Dict[str, Any], args: Sequence[str]) -> None:
|
||||
choice_text = f"choices: {', '.join(choices)}"
|
||||
desc = f"{desc} ({choice_text})" if desc else choice_text
|
||||
row.add_column("Description", desc)
|
||||
row.add_column("Example", example_text)
|
||||
if first_example_tokens:
|
||||
table.set_row_selection_args(len(table.rows) - 1, first_example_tokens)
|
||||
|
||||
ctx.set_last_result_table_overlay(table, [meta])
|
||||
ctx.set_current_stage_table(table)
|
||||
example_table = ResultTable(f"{cmd_name} Examples")
|
||||
example_table.set_preserve_order(True)
|
||||
example_table.set_header_line("Select @N to insert the example command into the REPL.")
|
||||
|
||||
example_items: List[str] = []
|
||||
if explicit_example:
|
||||
for idx, example_cmd in enumerate(explicit_example):
|
||||
example_text = str(example_cmd or "").strip()
|
||||
row = example_table.add_row()
|
||||
row.add_column("Example", example_text or "(empty example)")
|
||||
example_items.append(example_text)
|
||||
if example_text:
|
||||
tokens = _parse_example_tokens(example_text)
|
||||
if tokens:
|
||||
example_table.set_row_selection_args(idx, tokens)
|
||||
else:
|
||||
example_table.set_no_choice(True)
|
||||
row = example_table.add_row()
|
||||
row.add_column("Example", "(no examples available)")
|
||||
|
||||
ctx.set_last_result_table(example_table, example_items)
|
||||
ctx.set_current_stage_table(example_table)
|
||||
setattr(example_table, "_rendered_by_cmdlet", True)
|
||||
example_table.set_source_command(_HELP_EXAMPLE_SOURCE_COMMAND)
|
||||
from SYS.rich_display import stdout_console
|
||||
|
||||
stdout_console().print(table)
|
||||
stdout_console().print()
|
||||
stdout_console().print(args_table)
|
||||
stdout_console().print()
|
||||
stdout_console().print(example_table)
|
||||
stdout_console().print()
|
||||
|
||||
|
||||
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
|
||||
105
cmdnat/pipe.py
105
cmdnat/pipe.py
@@ -201,6 +201,69 @@ def _send_ipc_command(command: Dict[str, Any], silent: bool = False) -> Optional
|
||||
return None
|
||||
|
||||
|
||||
def _extract_store_and_hash(item: Any) -> tuple[Optional[str], Optional[str]]:
|
||||
store: Optional[str] = None
|
||||
file_hash: Optional[str] = None
|
||||
|
||||
try:
|
||||
if isinstance(item, dict):
|
||||
store = item.get("store")
|
||||
file_hash = item.get("hash") or item.get("file_hash")
|
||||
else:
|
||||
store = getattr(item, "store", None)
|
||||
file_hash = getattr(item, "hash", None) or getattr(item, "file_hash", None)
|
||||
except Exception:
|
||||
store = None
|
||||
file_hash = None
|
||||
|
||||
try:
|
||||
store = str(store).strip() if store else None
|
||||
except Exception:
|
||||
store = None
|
||||
|
||||
try:
|
||||
file_hash = str(file_hash).strip().lower() if file_hash else None
|
||||
except Exception:
|
||||
file_hash = None
|
||||
|
||||
if not file_hash:
|
||||
try:
|
||||
text = None
|
||||
if isinstance(item, dict):
|
||||
text = item.get("path") or item.get("url") or item.get("filename")
|
||||
else:
|
||||
text = getattr(item, "path", None) or getattr(item, "url", None)
|
||||
if text:
|
||||
m = re.search(r"[0-9a-f]{64}", str(text).lower())
|
||||
if m:
|
||||
file_hash = m.group(0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return store, file_hash
|
||||
|
||||
|
||||
def _set_mpv_item_context(store: Optional[str], file_hash: Optional[str]) -> None:
|
||||
# Properties consumed by MPV.lyric
|
||||
try:
|
||||
_send_ipc_command(
|
||||
{
|
||||
"command": ["set_property", "user-data/medeia-item-store", store or ""],
|
||||
"request_id": 901,
|
||||
},
|
||||
silent=True,
|
||||
)
|
||||
_send_ipc_command(
|
||||
{
|
||||
"command": ["set_property", "user-data/medeia-item-hash", file_hash or ""],
|
||||
"request_id": 902,
|
||||
},
|
||||
silent=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _get_playlist(silent: bool = False) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Get the current playlist from MPV. Returns None if MPV is not running."""
|
||||
cmd = {
|
||||
@@ -1014,6 +1077,15 @@ def _queue_items(
|
||||
if clear_first and i == 0:
|
||||
mode = "replace"
|
||||
|
||||
# If we're replacing, this will start playing immediately: set store/hash context
|
||||
# so MPV.lyric can resolve the correct backend for notes.
|
||||
if mode == "replace":
|
||||
try:
|
||||
s, h = _extract_store_and_hash(item)
|
||||
_set_mpv_item_context(s, h)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If this is a Hydrus path, set header property and yt-dlp headers before loading.
|
||||
# Use the real target (not the memory:// wrapper) for detection.
|
||||
if effective_hydrus_header and _is_hydrus_path(str(target),
|
||||
@@ -1209,7 +1281,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
result_obj = {
|
||||
"path": filename,
|
||||
"title": title,
|
||||
"cmdlet_name": ".pipe",
|
||||
"cmdlet_name": ".mpv",
|
||||
"source": "pipe",
|
||||
"__pipe_index": items.index(current_item),
|
||||
}
|
||||
@@ -1392,7 +1464,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# We also set the source command to .pipe -load <ID> so it loads it
|
||||
table.set_row_selection_args(i, ["-load", str(pl["id"])])
|
||||
|
||||
table.set_source_command(".pipe")
|
||||
table.set_source_command(".mpv")
|
||||
|
||||
# Register results
|
||||
ctx.set_last_result_table_overlay(
|
||||
@@ -1528,6 +1600,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
|
||||
if should_autoplay and after_len > 0:
|
||||
idx_to_play = min(max(0, before_len), after_len - 1)
|
||||
|
||||
# Prefer the store/hash from the piped item when auto-playing.
|
||||
try:
|
||||
s, h = _extract_store_and_hash(items_to_add[0])
|
||||
_set_mpv_item_context(s, h)
|
||||
except Exception:
|
||||
pass
|
||||
play_resp = _send_ipc_command(
|
||||
{
|
||||
"command": ["playlist-play-index",
|
||||
@@ -1657,6 +1736,11 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
return 1
|
||||
else:
|
||||
# Play item
|
||||
try:
|
||||
s, h = _extract_store_and_hash(item)
|
||||
_set_mpv_item_context(s, h)
|
||||
except Exception:
|
||||
pass
|
||||
if hydrus_header and _is_hydrus_path(filename, hydrus_url):
|
||||
header_cmd = {
|
||||
"command":
|
||||
@@ -1805,7 +1889,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
|
||||
table.set_row_selection_args(i, [str(i + 1)])
|
||||
|
||||
table.set_source_command(".pipe")
|
||||
table.set_source_command(".mpv")
|
||||
|
||||
# Register PipeObjects (not raw MPV items) with pipeline context
|
||||
ctx.set_last_result_table_overlay(table, pipe_objects)
|
||||
@@ -1980,6 +2064,15 @@ def _start_mpv(
|
||||
debug("Timed out waiting for MPV IPC connection", file=sys.stderr)
|
||||
return
|
||||
|
||||
# Publish context early so the lyric helper can resolve notes on the first
|
||||
# target change (the helper may start before playback begins).
|
||||
try:
|
||||
if items:
|
||||
s, h = _extract_store_and_hash(items[0])
|
||||
_set_mpv_item_context(s, h)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# main.lua is loaded at startup via --script; don't reload it here.
|
||||
|
||||
# Ensure lyric overlay is running (auto-discovery handled by MPV.lyric).
|
||||
@@ -2020,10 +2113,10 @@ def _start_mpv(
|
||||
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name=".pipe",
|
||||
alias=["pipe", "playlist", "queue", "ls-pipe"],
|
||||
name=".mpv",
|
||||
alias=[".pipe", "pipe", "playlist", "queue", "ls-pipe"],
|
||||
summary="Manage and play items in the MPV playlist via IPC",
|
||||
usage=".pipe [index|url] [-current] [-clear] [-list] [-url URL] [-log] [-borderless]",
|
||||
usage=".mpv [index|url] [-current] [-clear] [-list] [-url URL] [-log] [-borderless]",
|
||||
arg=[
|
||||
CmdletArg(
|
||||
name="index",
|
||||
|
||||
Reference in New Issue
Block a user