mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-27 16:37:55 +00:00
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.
129 lines
4.3 KiB
Python
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)
|