Ryker_Feng 8dafb667dd
fix(tui): derive /help text from the command registry (#4327)
The /help string was hardcoded and had drifted from BUILTIN_COMMANDS,
omitting six real commands (help, switch, resume, uploads, artifacts,
details) and ordering the rest differently from the picker. Generate the
command line from the registry so /help can never fall out of sync again,
and add parity tests that guard against future drift.
2026-07-22 07:53:50 +08:00

129 lines
4.3 KiB
Python

"""Slash-command registry for the DeerFlow TUI (pure).
Normalizes two command sources into one searchable list:
* **Built-ins** — TUI-owned affordances (``/help``, ``/model``, ``/threads`` …).
* **Skills** — one ``/<skill-name>`` per enabled skill, preserving DeerFlow's
existing slash-skill activation semantics.
The picker filters this list; :func:`resolve` classifies a submitted line as a
built-in command, a skill activation, an unknown command, or a plain message.
No Textual dependency.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
@dataclass(frozen=True)
class Command:
name: str # without leading slash
description: str
category: Literal["builtin", "skill"] = "builtin"
@dataclass(frozen=True)
class Resolution:
kind: Literal["builtin", "skill", "unknown", "message"]
name: str = ""
args: str = ""
text: str = ""
# Built-in commands, ordered for display in /help and the picker.
BUILTIN_COMMANDS: tuple[Command, ...] = (
Command("help", "Show commands and keybindings"),
Command("new", "Start a fresh thread"),
Command("clear", "Clear the transcript display"),
Command("threads", "Open the thread switcher"),
Command("switch", "Open the thread switcher"),
Command("resume", "Resume a thread by id or title"),
Command("goal", "Set, show or clear the active goal"),
Command("model", "Open the model picker"),
Command("skills", "Browse enabled and available skills"),
Command("tools", "Show built-in, MCP and sandbox tools"),
Command("mcp", "Show MCP server status"),
Command("memory", "Show memory status and injected facts"),
Command("uploads", "Show uploaded files for this thread"),
Command("artifacts", "Show generated artifacts"),
Command("details", "Toggle verbose activity rendering"),
Command("usage", "Show token usage and context"),
Command("config", "Show resolved config paths and overrides"),
Command("quit", "Exit the TUI"),
)
_BUILTIN_NAMES = frozenset(c.name for c in BUILTIN_COMMANDS)
def format_command_help() -> str:
"""One-line summary of every built-in slash command, for ``/help``.
Derived from :data:`BUILTIN_COMMANDS` so the help text can never drift out
of sync with the registry (and, therefore, the picker). Adding a built-in
surfaces it in ``/help`` automatically.
"""
names = " ".join(f"/{command.name}" for command in BUILTIN_COMMANDS)
return f"Commands: {names}"
def build_registry(skills: list[dict]) -> list[Command]:
"""Merge built-ins with one command per enabled skill."""
commands = list(BUILTIN_COMMANDS)
for skill in skills:
if not skill.get("enabled", False):
continue
name = skill.get("name")
if not name or name in _BUILTIN_NAMES:
continue
commands.append(Command(name=name, description=skill.get("description", "") or "", category="skill"))
return commands
def filter_commands(commands: list[Command], query: str) -> list[Command]:
"""Filter + rank commands for the picker.
Ranking: name-prefix matches first, then name-substring, then
description-substring. Original order is preserved within a rank tier.
"""
q = query.strip().lower()
if not q:
return commands
prefix: list[Command] = []
substring: list[Command] = []
description: list[Command] = []
for command in commands:
name = command.name.lower()
if name.startswith(q):
prefix.append(command)
elif q in name:
substring.append(command)
elif q in command.description.lower():
description.append(command)
return prefix + substring + description
def resolve(text: str, skills: list[str] | None = None) -> Resolution:
"""Classify a submitted input line."""
stripped = text.strip()
if not stripped.startswith("/"):
return Resolution(kind="message", text=text)
body = stripped[1:]
name, _, args = body.partition(" ")
name = name.strip()
args = args.strip()
if not name:
return Resolution(kind="unknown", name="")
if name in _BUILTIN_NAMES:
return Resolution(kind="builtin", name=name, args=args)
if skills and name in skills:
return Resolution(kind="skill", name=name, args=args)
return Resolution(kind="unknown", name=name, args=args)