From 8dafb667dd32f3933a2aaabb6fecbe506607699c Mon Sep 17 00:00:00 2001 From: Ryker_Feng <90562015+18062706139fcz@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:53:50 +0800 Subject: [PATCH] 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. --- backend/packages/harness/deerflow/tui/app.py | 4 ++- .../harness/deerflow/tui/command_registry.py | 11 ++++++ backend/tests/test_tui_app.py | 21 +++++++++++ backend/tests/test_tui_command_registry.py | 36 +++++++++++++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) diff --git a/backend/packages/harness/deerflow/tui/app.py b/backend/packages/harness/deerflow/tui/app.py index 0cffd4e20..99efaf880 100644 --- a/backend/packages/harness/deerflow/tui/app.py +++ b/backend/packages/harness/deerflow/tui/app.py @@ -20,6 +20,7 @@ from textual.widgets.option_list import Option from deerflow.runtime.goal import parse_goal_command +from .command_registry import format_command_help from .input_history import InputHistory from .render import render_header, render_status, render_transcript from .runtime import stream_actions @@ -36,7 +37,8 @@ from .view_state import ( ) from .widgets.composer import ComposerInput -_HELP_TEXT = "Commands: /new /clear /threads /goal /model /skills /tools /mcp /memory /usage /config /quit\nKeys: Enter send · Ctrl+C interrupt or quit · Ctrl+L redraw · / commands · Esc close overlay" +_HELP_KEYS = "Keys: Enter send · Ctrl+C interrupt or quit · Ctrl+L redraw · / commands · Esc close overlay" +_HELP_TEXT = f"{format_command_help()}\n{_HELP_KEYS}" class SelectScreen(ModalScreen): diff --git a/backend/packages/harness/deerflow/tui/command_registry.py b/backend/packages/harness/deerflow/tui/command_registry.py index 74f35f9e4..5c523e852 100644 --- a/backend/packages/harness/deerflow/tui/command_registry.py +++ b/backend/packages/harness/deerflow/tui/command_registry.py @@ -57,6 +57,17 @@ BUILTIN_COMMANDS: tuple[Command, ...] = ( _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) diff --git a/backend/tests/test_tui_app.py b/backend/tests/test_tui_app.py index e25905d9e..0a4498f49 100644 --- a/backend/tests/test_tui_app.py +++ b/backend/tests/test_tui_app.py @@ -95,10 +95,31 @@ async def test_help_command_renders_system_row_without_calling_agent(): assert any(r.kind == "system" for r in app.state.rows) assert any(r.kind == "system" and "/clear" in r.text for r in app.state.rows) + # Commands previously missing from the hardcoded help string must now appear, + # since the help text is derived from the command registry. + help_rows = [r for r in app.state.rows if r.kind == "system"] + for command in ("/help", "/resume", "/switch", "/uploads", "/artifacts", "/details"): + assert any(command in r.text for r in help_rows), command # /help must not produce a user turn or start streaming. assert not any(r.kind == "user" for r in app.state.rows) +@pytest.mark.asyncio +async def test_help_text_matches_command_registry(): + from deerflow.tui.command_registry import format_command_help + + app = DeerFlowTUI(_FakeSession(), LaunchPlan(mode="tui")) + async with app.run_test() as pilot: + await pilot.pause() + for ch in "/help": + await pilot.press(ch) + await pilot.press("enter") + await _wait_until(lambda: any(r.kind == "system" for r in app.state.rows), pilot) + + expected = format_command_help() + assert any(r.kind == "system" and expected in r.text for r in app.state.rows) + + @pytest.mark.asyncio async def test_clear_command_clears_display_without_resetting_thread_or_calling_agent(): session = _FakeSession() diff --git a/backend/tests/test_tui_command_registry.py b/backend/tests/test_tui_command_registry.py index 48e6df3e2..558b063d7 100644 --- a/backend/tests/test_tui_command_registry.py +++ b/backend/tests/test_tui_command_registry.py @@ -4,6 +4,7 @@ from deerflow.tui.command_registry import ( BUILTIN_COMMANDS, build_registry, filter_commands, + format_command_help, resolve, ) @@ -115,3 +116,38 @@ def test_goal_builtin_takes_precedence_over_skill(): assert [command.name for command in registry].count("goal") == 1 assert resolve("/goal finish", skills=["goal"]).kind == "builtin" + + +# --------------------------------------------------------------------------- # +# /help text <-> registry parity +# --------------------------------------------------------------------------- # + + +def test_help_lists_every_builtin_command(): + help_line = format_command_help() + # Every registered built-in must be advertised in /help; this is the guard + # against the help text silently drifting from BUILTIN_COMMANDS. + for command in BUILTIN_COMMANDS: + assert f"/{command.name}" in help_line + + +def test_help_lists_only_builtin_commands(): + help_line = format_command_help() + slugs = [token[1:] for token in help_line.split() if token.startswith("/")] + assert set(slugs) == {command.name for command in BUILTIN_COMMANDS} + + +def test_help_preserves_registry_order(): + help_line = format_command_help() + slugs = [token[1:] for token in help_line.split() if token.startswith("/")] + assert slugs == [command.name for command in BUILTIN_COMMANDS] + + +def test_help_has_no_duplicate_commands(): + help_line = format_command_help() + slugs = [token[1:] for token in help_line.split() if token.startswith("/")] + assert len(slugs) == len(set(slugs)) + + +def test_help_starts_with_commands_label(): + assert format_command_help().startswith("Commands: /")