From b1f55e3ee578b2779d7598c97992142f7ea38596 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 21 Feb 2026 23:17:18 -0800 Subject: [PATCH] refactor: reorganize agent and CLI structure for improved clarity - Extracted agent internals into a dedicated `agent/` directory, including model metadata, context compression, and prompt handling. - Enhanced CLI structure by separating banner, commands, and callbacks into distinct modules within `hermes_cli/`. - Updated README to reflect the new directory organization and clarify the purpose of each component. - Improved tool registration and terminal execution backends for better maintainability and usability. --- AGENTS.md | 45 +++++--- README.md | 9 +- cli.py | 10 ++ hermes_cli/banner.py | 234 ++++++++++++++++++++++++++++++++++++++++ hermes_cli/callbacks.py | 145 +++++++++++++++++++++++++ hermes_cli/commands.py | 48 +++++++++ 6 files changed, 473 insertions(+), 18 deletions(-) create mode 100644 hermes_cli/banner.py create mode 100644 hermes_cli/callbacks.py create mode 100644 hermes_cli/commands.py diff --git a/AGENTS.md b/AGENTS.md index 46a42a6f..8ba3332c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,8 +15,18 @@ source venv/bin/activate # Before running any Python commands ``` hermes-agent/ -├── hermes_cli/ # Unified CLI commands +├── agent/ # Agent internals (extracted from run_agent.py) +│ ├── model_metadata.py # Model context lengths, token estimation +│ ├── context_compressor.py # Auto context compression +│ ├── prompt_caching.py # Anthropic prompt caching +│ ├── prompt_builder.py # System prompt assembly (identity, skills index, context files) +│ ├── display.py # KawaiiSpinner, tool preview formatting +│ └── trajectory.py # Trajectory saving helpers +├── hermes_cli/ # CLI implementation │ ├── main.py # Entry point, command dispatcher +│ ├── banner.py # Welcome banner, ASCII art, skills summary +│ ├── commands.py # Slash command definitions + autocomplete +│ ├── callbacks.py # Interactive prompt callbacks (clarify, sudo, approval) │ ├── setup.py # Interactive setup wizard │ ├── config.py # Config management & migration │ ├── status.py # Status display @@ -26,23 +36,28 @@ hermes-agent/ │ ├── cron.py # Cron job management │ └── skills_hub.py # Skills Hub CLI + /skills slash command ├── tools/ # Tool implementations -│ ├── skills_guard.py # Security scanner for external skills -│ ├── skills_hub.py # Source adapters, GitHub auth, lock file (library) -│ ├── todo_tool.py # Planning & task management (in-memory TodoStore) -│ ├── process_registry.py # Background process management (spawn, poll, wait, kill) -│ ├── transcription_tools.py # Speech-to-text (Whisper API) +│ ├── registry.py # Central tool registry (schemas, handlers, dispatch) +│ ├── approval.py # Dangerous command detection + per-session approval +│ ├── environments/ # Terminal execution backends +│ │ ├── base.py # BaseEnvironment ABC +│ │ ├── local.py # Local execution with interrupt support +│ │ ├── docker.py # Docker container execution +│ │ ├── ssh.py # SSH remote execution +│ │ ├── singularity.py # Singularity/Apptainer + SIF management +│ │ └── modal.py # Modal cloud execution +│ ├── terminal_tool.py # Terminal orchestration (sudo, lifecycle, factory) +│ ├── todo_tool.py # Planning & task management +│ ├── process_registry.py # Background process management +│ └── ... # Other tool files ├── gateway/ # Messaging platform adapters -│ ├── pairing.py # DM pairing code system -│ ├── hooks.py # Event hook system -│ ├── sticker_cache.py # Telegram sticker vision cache -│ ├── platforms/ -│ │ └── slack.py # Slack adapter (slack-bolt) +│ ├── platforms/ # Platform-specific adapters (telegram, discord, slack, whatsapp) +│ └── ... ├── cron/ # Scheduler implementation -├── skills/ # Knowledge documents -├── cli.py # Interactive CLI (Rich UI) -├── run_agent.py # Agent runner with AIAgent class +├── environments/ # RL training environments (Atropos integration) +├── skills/ # Bundled skill sources +├── cli.py # Interactive CLI orchestrator (HermesCLI class) +├── run_agent.py # AIAgent class (core conversation loop) ├── model_tools.py # Tool orchestration (thin layer over tools/registry.py) -├── tools/registry.py # Central tool registry (schemas, handlers, dispatch) ├── toolsets.py # Tool groupings ├── toolset_distributions.py # Probability-based tool selection └── batch_runner.py # Parallel batch processing diff --git a/README.md b/README.md index f45aa765..cf2fd277 100644 --- a/README.md +++ b/README.md @@ -1374,9 +1374,12 @@ All variables go in `~/.hermes/.env`. Run `hermes config set VAR value` to set t | `~/.hermes/cron/` | Scheduled jobs data | | `~/.hermes/sessions/` | Gateway session data | | `~/.hermes/hermes-agent/` | Installation directory | -| `hermes_cli/` | CLI implementation | -| `hermes_cli/auth.py` | Multi-provider auth system | -| `tools/` | Tool implementations | +| `agent/` | Agent internals (context compressor, prompt builder, display, etc.) | +| `hermes_cli/` | CLI implementation (banner, commands, callbacks, config, auth) | +| `tools/` | Tool implementations + central registry (`tools/registry.py`) | +| `tools/environments/` | Terminal execution backends (local, docker, ssh, singularity, modal) | +| `tools/approval.py` | Dangerous command detection + per-session approval state | +| `model_tools.py` | Tool orchestration (thin layer over `tools/registry.py`) | | `skills/` | Bundled skill sources (copied to `~/.hermes/skills/` on install) | | `~/.hermes/skills/` | All active skills (bundled + hub-installed + agent-created) | | `gateway/` | Messaging platform adapters | diff --git a/cli.py b/cli.py index 9a6a3f4b..1972a18e 100755 --- a/cli.py +++ b/cli.py @@ -275,6 +275,16 @@ import fire # Import the agent and tool systems from run_agent import AIAgent from model_tools import get_tool_definitions, get_toolset_for_tool + +# Extracted CLI modules (Phase 3) +from hermes_cli.banner import ( + cprint as _cprint, _GOLD, _BOLD, _DIM, _RST, + VERSION, HERMES_AGENT_LOGO, HERMES_CADUCEUS, COMPACT_BANNER, + get_available_skills as _get_available_skills, + build_welcome_banner, +) +from hermes_cli.commands import COMMANDS, SlashCommandCompleter +from hermes_cli import callbacks as _callbacks from toolsets import get_all_toolsets, get_toolset_info, resolve_toolset, validate_toolset # Cron job system for scheduled tasks (CRUD only — execution is handled by the gateway) diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py new file mode 100644 index 00000000..2597e880 --- /dev/null +++ b/hermes_cli/banner.py @@ -0,0 +1,234 @@ +"""Welcome banner, ASCII art, and skills summary for the CLI. + +Pure display functions with no HermesCLI state dependency. +""" + +from pathlib import Path +from typing import Dict, List, Any + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from prompt_toolkit import print_formatted_text as _pt_print +from prompt_toolkit.formatted_text import ANSI as _PT_ANSI + + +# ========================================================================= +# ANSI building blocks for conversation display +# ========================================================================= + +_GOLD = "\033[1;33m" +_BOLD = "\033[1m" +_DIM = "\033[2m" +_RST = "\033[0m" + + +def cprint(text: str): + """Print ANSI-colored text through prompt_toolkit's renderer.""" + _pt_print(_PT_ANSI(text)) + + +# ========================================================================= +# ASCII Art & Branding +# ========================================================================= + +VERSION = "v1.0.0" + +HERMES_AGENT_LOGO = """[bold #FFD700]██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗[/] +[bold #FFD700]██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝[/] +[#FFBF00]███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║[/] +[#FFBF00]██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║[/] +[#CD7F32]██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║[/] +[#CD7F32]╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝[/]""" + +HERMES_CADUCEUS = """[#CD7F32]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡀⠀⣀⣀⠀⢀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#CD7F32]⠀⠀⠀⠀⠀⠀⢀⣠⣴⣾⣿⣿⣇⠸⣿⣿⠇⣸⣿⣿⣷⣦⣄⡀⠀⠀⠀⠀⠀⠀[/] +[#FFBF00]⠀⢀⣠⣴⣶⠿⠋⣩⡿⣿⡿⠻⣿⡇⢠⡄⢸⣿⠟⢿⣿⢿⣍⠙⠿⣶⣦⣄⡀⠀[/] +[#FFBF00]⠀⠀⠉⠉⠁⠶⠟⠋⠀⠉⠀⢀⣈⣁⡈⢁⣈⣁⡀⠀⠉⠀⠙⠻⠶⠈⠉⠉⠀⠀[/] +[#FFD700]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⡿⠛⢁⡈⠛⢿⣿⣦⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#FFD700]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠿⣿⣦⣤⣈⠁⢠⣴⣿⠿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#FFBF00]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠻⢿⣿⣦⡉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#FFBF00]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⢷⣦⣈⠛⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#CD7F32]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣴⠦⠈⠙⠿⣦⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#CD7F32]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣿⣤⡈⠁⢤⣿⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#B8860B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠷⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#B8860B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⠑⢶⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#B8860B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠁⢰⡆⠈⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#B8860B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠳⠈⣡⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/] +[#B8860B]⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀[/]""" + +COMPACT_BANNER = """ +[bold #FFD700]╔══════════════════════════════════════════════════════════════╗[/] +[bold #FFD700]║[/] [#FFBF00]⚕ NOUS HERMES[/] [dim #B8860B]- AI Agent Framework[/] [bold #FFD700]║[/] +[bold #FFD700]║[/] [#CD7F32]Messenger of the Digital Gods[/] [dim #B8860B]Nous Research[/] [bold #FFD700]║[/] +[bold #FFD700]╚══════════════════════════════════════════════════════════════╝[/] +""" + + +# ========================================================================= +# Skills scanning +# ========================================================================= + +def get_available_skills() -> Dict[str, List[str]]: + """Scan ~/.hermes/skills/ and return skills grouped by category.""" + import os + + hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) + skills_dir = hermes_home / "skills" + skills_by_category = {} + + if not skills_dir.exists(): + return skills_by_category + + for skill_file in skills_dir.rglob("SKILL.md"): + rel_path = skill_file.relative_to(skills_dir) + parts = rel_path.parts + if len(parts) >= 2: + category = parts[0] + skill_name = parts[-2] + else: + category = "general" + skill_name = skill_file.parent.name + skills_by_category.setdefault(category, []).append(skill_name) + + return skills_by_category + + +# ========================================================================= +# Welcome banner +# ========================================================================= + +def build_welcome_banner(console: Console, model: str, cwd: str, + tools: List[dict] = None, + enabled_toolsets: List[str] = None, + session_id: str = None, + get_toolset_for_tool=None): + """Build and print a welcome banner with caduceus on left and info on right. + + Args: + console: Rich Console instance. + model: Current model name. + cwd: Current working directory. + tools: List of tool definitions. + enabled_toolsets: List of enabled toolset names. + session_id: Session identifier. + get_toolset_for_tool: Callable to map tool name -> toolset name. + """ + from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS + if get_toolset_for_tool is None: + from model_tools import get_toolset_for_tool + + tools = tools or [] + enabled_toolsets = enabled_toolsets or [] + + _, unavailable_toolsets = check_tool_availability(quiet=True) + disabled_tools = set() + for item in unavailable_toolsets: + disabled_tools.update(item.get("tools", [])) + + layout_table = Table.grid(padding=(0, 2)) + layout_table.add_column("left", justify="center") + layout_table.add_column("right", justify="left") + + left_lines = ["", HERMES_CADUCEUS, ""] + model_short = model.split("/")[-1] if "/" in model else model + if len(model_short) > 28: + model_short = model_short[:25] + "..." + left_lines.append(f"[#FFBF00]{model_short}[/] [dim #B8860B]·[/] [dim #B8860B]Nous Research[/]") + left_lines.append(f"[dim #B8860B]{cwd}[/]") + if session_id: + left_lines.append(f"[dim #8B8682]Session: {session_id}[/]") + left_content = "\n".join(left_lines) + + right_lines = ["[bold #FFBF00]Available Tools[/]"] + toolsets_dict: Dict[str, list] = {} + + for tool in tools: + tool_name = tool["function"]["name"] + toolset = get_toolset_for_tool(tool_name) or "other" + toolsets_dict.setdefault(toolset, []).append(tool_name) + + for item in unavailable_toolsets: + toolset_id = item.get("id", item.get("name", "unknown")) + display_name = f"{toolset_id}_tools" if not toolset_id.endswith("_tools") else toolset_id + if display_name not in toolsets_dict: + toolsets_dict[display_name] = [] + for tool_name in item.get("tools", []): + if tool_name not in toolsets_dict[display_name]: + toolsets_dict[display_name].append(tool_name) + + sorted_toolsets = sorted(toolsets_dict.keys()) + display_toolsets = sorted_toolsets[:8] + remaining_toolsets = len(sorted_toolsets) - 8 + + for toolset in display_toolsets: + tool_names = toolsets_dict[toolset] + colored_names = [] + for name in sorted(tool_names): + if name in disabled_tools: + colored_names.append(f"[red]{name}[/]") + else: + colored_names.append(f"[#FFF8DC]{name}[/]") + + tools_str = ", ".join(colored_names) + if len(", ".join(sorted(tool_names))) > 45: + short_names = [] + length = 0 + for name in sorted(tool_names): + if length + len(name) + 2 > 42: + short_names.append("...") + break + short_names.append(name) + length += len(name) + 2 + colored_names = [] + for name in short_names: + if name == "...": + colored_names.append("[dim]...[/]") + elif name in disabled_tools: + colored_names.append(f"[red]{name}[/]") + else: + colored_names.append(f"[#FFF8DC]{name}[/]") + tools_str = ", ".join(colored_names) + + right_lines.append(f"[dim #B8860B]{toolset}:[/] {tools_str}") + + if remaining_toolsets > 0: + right_lines.append(f"[dim #B8860B](and {remaining_toolsets} more toolsets...)[/]") + + right_lines.append("") + right_lines.append("[bold #FFBF00]Available Skills[/]") + skills_by_category = get_available_skills() + total_skills = sum(len(s) for s in skills_by_category.values()) + + if skills_by_category: + for category in sorted(skills_by_category.keys()): + skill_names = sorted(skills_by_category[category]) + if len(skill_names) > 8: + display_names = skill_names[:8] + skills_str = ", ".join(display_names) + f" +{len(skill_names) - 8} more" + else: + skills_str = ", ".join(skill_names) + if len(skills_str) > 50: + skills_str = skills_str[:47] + "..." + right_lines.append(f"[dim #B8860B]{category}:[/] [#FFF8DC]{skills_str}[/]") + else: + right_lines.append("[dim #B8860B]No skills installed[/]") + + right_lines.append("") + right_lines.append(f"[dim #B8860B]{len(tools)} tools · {total_skills} skills · /help for commands[/]") + + right_content = "\n".join(right_lines) + layout_table.add_row(left_content, right_content) + + outer_panel = Panel( + layout_table, + title=f"[bold #FFD700]Hermes Agent {VERSION}[/]", + border_style="#CD7F32", + padding=(0, 2), + ) + + console.print() + console.print(HERMES_AGENT_LOGO) + console.print() + console.print(outer_panel) diff --git a/hermes_cli/callbacks.py b/hermes_cli/callbacks.py new file mode 100644 index 00000000..bfce9c00 --- /dev/null +++ b/hermes_cli/callbacks.py @@ -0,0 +1,145 @@ +"""Interactive prompt callbacks for terminal_tool integration. + +These bridge terminal_tool's interactive prompts (clarify, sudo, approval) +into prompt_toolkit's event loop. Each function takes the HermesCLI instance +as its first argument and uses its state (queues, app reference) to coordinate +with the TUI. +""" + +import queue +import time as _time + +from hermes_cli.banner import cprint, _DIM, _RST + + +def clarify_callback(cli, question, choices): + """Prompt for clarifying question through the TUI. + + Sets up the interactive selection UI, then blocks until the user + responds. Returns the user's choice or a timeout message. + """ + from cli import CLI_CONFIG + + timeout = CLI_CONFIG.get("clarify", {}).get("timeout", 120) + response_queue = queue.Queue() + is_open_ended = not choices or len(choices) == 0 + + cli._clarify_state = { + "question": question, + "choices": choices if not is_open_ended else [], + "selected": 0, + "response_queue": response_queue, + } + cli._clarify_deadline = _time.monotonic() + timeout + cli._clarify_freetext = is_open_ended + + if hasattr(cli, '_app') and cli._app: + cli._app.invalidate() + + while True: + try: + result = response_queue.get(timeout=1) + cli._clarify_deadline = 0 + return result + except queue.Empty: + remaining = cli._clarify_deadline - _time.monotonic() + if remaining <= 0: + break + if hasattr(cli, '_app') and cli._app: + cli._app.invalidate() + + cli._clarify_state = None + cli._clarify_freetext = False + cli._clarify_deadline = 0 + if hasattr(cli, '_app') and cli._app: + cli._app.invalidate() + cprint(f"\n{_DIM}(clarify timed out after {timeout}s — agent will decide){_RST}") + return ( + "The user did not provide a response within the time limit. " + "Use your best judgement to make the choice and proceed." + ) + + +def sudo_password_callback(cli) -> str: + """Prompt for sudo password through the TUI. + + Sets up a password input area and blocks until the user responds. + """ + timeout = 45 + response_queue = queue.Queue() + + cli._sudo_state = {"response_queue": response_queue} + cli._sudo_deadline = _time.monotonic() + timeout + + if hasattr(cli, '_app') and cli._app: + cli._app.invalidate() + + while True: + try: + result = response_queue.get(timeout=1) + cli._sudo_state = None + cli._sudo_deadline = 0 + if hasattr(cli, '_app') and cli._app: + cli._app.invalidate() + if result: + cprint(f"\n{_DIM} ✓ Password received (cached for session){_RST}") + else: + cprint(f"\n{_DIM} ⏭ Skipped{_RST}") + return result + except queue.Empty: + remaining = cli._sudo_deadline - _time.monotonic() + if remaining <= 0: + break + if hasattr(cli, '_app') and cli._app: + cli._app.invalidate() + + cli._sudo_state = None + cli._sudo_deadline = 0 + if hasattr(cli, '_app') and cli._app: + cli._app.invalidate() + cprint(f"\n{_DIM} ⏱ Timeout — continuing without sudo{_RST}") + return "" + + +def approval_callback(cli, command: str, description: str) -> str: + """Prompt for dangerous command approval through the TUI. + + Shows a selection UI with choices: once / session / always / deny. + """ + timeout = 60 + response_queue = queue.Queue() + choices = ["once", "session", "always", "deny"] + + cli._approval_state = { + "command": command, + "description": description, + "choices": choices, + "selected": 0, + "response_queue": response_queue, + } + cli._approval_deadline = _time.monotonic() + timeout + + if hasattr(cli, '_app') and cli._app: + cli._app.invalidate() + + while True: + try: + result = response_queue.get(timeout=1) + cli._approval_state = None + cli._approval_deadline = 0 + if hasattr(cli, '_app') and cli._app: + cli._app.invalidate() + return result + except queue.Empty: + remaining = cli._approval_deadline - _time.monotonic() + if remaining <= 0: + break + if hasattr(cli, '_app') and cli._app: + cli._app.invalidate() + + cli._approval_state = None + cli._approval_deadline = 0 + if hasattr(cli, '_app') and cli._app: + cli._app.invalidate() + cprint(f"\n{_DIM} ⏱ Timeout — denying command{_RST}") + return "deny" diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py new file mode 100644 index 00000000..7485e3a2 --- /dev/null +++ b/hermes_cli/commands.py @@ -0,0 +1,48 @@ +"""Slash command definitions and autocomplete for the Hermes CLI. + +Contains the COMMANDS dict and the SlashCommandCompleter class. +These are pure data/UI with no HermesCLI state dependency. +""" + +from prompt_toolkit.completion import Completer, Completion + + +COMMANDS = { + "/help": "Show this help message", + "/tools": "List available tools", + "/toolsets": "List available toolsets", + "/model": "Show or change the current model", + "/prompt": "View/set custom system prompt", + "/personality": "Set a predefined personality", + "/clear": "Clear screen and reset conversation (fresh start)", + "/history": "Show conversation history", + "/new": "Start a new conversation (reset history)", + "/reset": "Reset conversation only (keep screen)", + "/retry": "Retry the last message (resend to agent)", + "/undo": "Remove the last user/assistant exchange", + "/save": "Save the current conversation", + "/config": "Show current configuration", + "/cron": "Manage scheduled tasks (list, add, remove)", + "/skills": "Search, install, inspect, or manage skills from online registries", + "/platforms": "Show gateway/messaging platform status", + "/quit": "Exit the CLI (also: /exit, /q)", +} + + +class SlashCommandCompleter(Completer): + """Autocomplete for /commands in the input area.""" + + def get_completions(self, document, complete_event): + text = document.text_before_cursor + if not text.startswith("/"): + return + word = text[1:] + for cmd, desc in COMMANDS.items(): + cmd_name = cmd[1:] + if cmd_name.startswith(word): + yield Completion( + cmd_name, + start_position=-len(word), + display=cmd, + display_meta=desc, + )