From 2ba219fa4b96fa649807d881e643ea3f00c735d0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 16 Mar 2026 06:07:45 -0700 Subject: [PATCH] feat(cli): add file path autocomplete in the input prompt (#1545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When typing a path-like token (./ ../ ~/ / or containing /), the CLI now shows filesystem completions in the dropdown menu. Directories show a trailing slash and 'dir' label; files show their size. Completions are case-insensitive and capped at 30 entries. Triggered by tokens like: edit ./src/ma → shows ./src/main.py, ./src/manifest.json, ... check ~/doc → shows ~/docs/, ~/documents/, ... read /etc/hos → shows /etc/hosts, /etc/hostname, ... open tools/reg → shows tools/registry.py Slash command autocomplete (/help, /model, etc.) is unaffected — it still triggers when the input starts with /. Inspired by OpenCode PR #145 (file path completion menu). Implementation: - hermes_cli/commands.py: _extract_path_word() detects path-like tokens, _path_completions() yields filesystem Completions with size labels, get_completions() routes to paths vs slash commands - tests/hermes_cli/test_path_completion.py: 26 tests covering path extraction, prefix filtering, directory markers, home expansion, case-insensitivity, integration with slash commands --- hermes_cli/commands.py | 96 ++++++++++++ tests/hermes_cli/test_path_completion.py | 184 +++++++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 tests/hermes_cli/test_path_completion.py diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 7e964bd4e..3a9b5b712 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -7,7 +7,9 @@ interactive CLI. from __future__ import annotations +import os from collections.abc import Callable, Mapping +from pathlib import Path from typing import Any from prompt_toolkit.completion import Completer, Completion @@ -92,9 +94,88 @@ class SlashCommandCompleter(Completer): """ return f"{cmd_name} " if cmd_name == word else cmd_name + @staticmethod + def _extract_path_word(text: str) -> str | None: + """Extract the current word if it looks like a file path. + + Returns the path-like token under the cursor, or None if the + current word doesn't look like a path. A word is path-like when + it starts with ``./``, ``../``, ``~/``, ``/``, or contains a + ``/`` separator (e.g. ``src/main.py``). + """ + if not text: + return None + # Walk backwards to find the start of the current "word". + # Words are delimited by spaces, but paths can contain almost anything. + i = len(text) - 1 + while i >= 0 and text[i] != " ": + i -= 1 + word = text[i + 1:] + if not word: + return None + # Only trigger path completion for path-like tokens + if word.startswith(("./", "../", "~/", "/")) or "/" in word: + return word + return None + + @staticmethod + def _path_completions(word: str, limit: int = 30): + """Yield Completion objects for file paths matching *word*.""" + expanded = os.path.expanduser(word) + # Split into directory part and prefix to match inside it + if expanded.endswith("/"): + search_dir = expanded + prefix = "" + else: + search_dir = os.path.dirname(expanded) or "." + prefix = os.path.basename(expanded) + + try: + entries = os.listdir(search_dir) + except OSError: + return + + count = 0 + prefix_lower = prefix.lower() + for entry in sorted(entries): + if prefix and not entry.lower().startswith(prefix_lower): + continue + if count >= limit: + break + + full_path = os.path.join(search_dir, entry) + is_dir = os.path.isdir(full_path) + + # Build the completion text (what replaces the typed word) + if word.startswith("~"): + display_path = "~/" + os.path.relpath(full_path, os.path.expanduser("~")) + elif os.path.isabs(word): + display_path = full_path + else: + # Keep relative + display_path = os.path.relpath(full_path) + + if is_dir: + display_path += "/" + + suffix = "/" if is_dir else "" + meta = "dir" if is_dir else _file_size_label(full_path) + + yield Completion( + display_path, + start_position=-len(word), + display=entry + suffix, + display_meta=meta, + ) + count += 1 + def get_completions(self, document, complete_event): text = document.text_before_cursor if not text.startswith("/"): + # Try file path completion for non-slash input + path_word = self._extract_path_word(text) + if path_word is not None: + yield from self._path_completions(path_word) return word = text[1:] @@ -120,3 +201,18 @@ class SlashCommandCompleter(Completer): display=cmd, display_meta=f"⚡ {short_desc}", ) + + +def _file_size_label(path: str) -> str: + """Return a compact human-readable file size, or '' on error.""" + try: + size = os.path.getsize(path) + except OSError: + return "" + if size < 1024: + return f"{size}B" + if size < 1024 * 1024: + return f"{size / 1024:.0f}K" + if size < 1024 * 1024 * 1024: + return f"{size / (1024 * 1024):.1f}M" + return f"{size / (1024 * 1024 * 1024):.1f}G" diff --git a/tests/hermes_cli/test_path_completion.py b/tests/hermes_cli/test_path_completion.py new file mode 100644 index 000000000..b41a36e2e --- /dev/null +++ b/tests/hermes_cli/test_path_completion.py @@ -0,0 +1,184 @@ +"""Tests for file path autocomplete in the CLI completer.""" + +import os +from unittest.mock import MagicMock + +import pytest +from prompt_toolkit.document import Document +from prompt_toolkit.formatted_text import to_plain_text + +from hermes_cli.commands import SlashCommandCompleter, _file_size_label + + +def _display_names(completions): + """Extract plain-text display names from a list of Completion objects.""" + return [to_plain_text(c.display) for c in completions] + + +def _display_metas(completions): + """Extract plain-text display_meta from a list of Completion objects.""" + return [to_plain_text(c.display_meta) if c.display_meta else "" for c in completions] + + +@pytest.fixture +def completer(): + return SlashCommandCompleter() + + +class TestExtractPathWord: + def test_relative_path(self): + assert SlashCommandCompleter._extract_path_word("look at ./src/main.py") == "./src/main.py" + + def test_home_path(self): + assert SlashCommandCompleter._extract_path_word("edit ~/docs/") == "~/docs/" + + def test_absolute_path(self): + assert SlashCommandCompleter._extract_path_word("read /etc/hosts") == "/etc/hosts" + + def test_parent_path(self): + assert SlashCommandCompleter._extract_path_word("check ../config.yaml") == "../config.yaml" + + def test_path_with_slash_in_middle(self): + assert SlashCommandCompleter._extract_path_word("open src/utils/helpers.py") == "src/utils/helpers.py" + + def test_plain_word_not_path(self): + assert SlashCommandCompleter._extract_path_word("hello world") is None + + def test_empty_string(self): + assert SlashCommandCompleter._extract_path_word("") is None + + def test_single_word_no_slash(self): + assert SlashCommandCompleter._extract_path_word("README.md") is None + + def test_word_after_space(self): + assert SlashCommandCompleter._extract_path_word("fix the bug in ./tools/") == "./tools/" + + def test_just_dot_slash(self): + assert SlashCommandCompleter._extract_path_word("./") == "./" + + def test_just_tilde_slash(self): + assert SlashCommandCompleter._extract_path_word("~/") == "~/" + + +class TestPathCompletions: + def test_lists_current_directory(self, tmp_path): + (tmp_path / "file_a.py").touch() + (tmp_path / "file_b.txt").touch() + (tmp_path / "subdir").mkdir() + + old_cwd = os.getcwd() + os.chdir(tmp_path) + try: + completions = list(SlashCommandCompleter._path_completions("./")) + names = _display_names(completions) + assert "file_a.py" in names + assert "file_b.txt" in names + assert "subdir/" in names + finally: + os.chdir(old_cwd) + + def test_filters_by_prefix(self, tmp_path): + (tmp_path / "alpha.py").touch() + (tmp_path / "beta.py").touch() + (tmp_path / "alpha_test.py").touch() + + completions = list(SlashCommandCompleter._path_completions(f"{tmp_path}/alpha")) + names = _display_names(completions) + assert "alpha.py" in names + assert "alpha_test.py" in names + assert "beta.py" not in names + + def test_directories_have_trailing_slash(self, tmp_path): + (tmp_path / "mydir").mkdir() + (tmp_path / "myfile.txt").touch() + + completions = list(SlashCommandCompleter._path_completions(f"{tmp_path}/")) + names = _display_names(completions) + metas = _display_metas(completions) + assert "mydir/" in names + idx = names.index("mydir/") + assert metas[idx] == "dir" + + def test_home_expansion(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / "testfile.md").touch() + + completions = list(SlashCommandCompleter._path_completions("~/test")) + names = _display_names(completions) + assert "testfile.md" in names + + def test_nonexistent_dir_returns_empty(self): + completions = list(SlashCommandCompleter._path_completions("/nonexistent_dir_xyz/")) + assert completions == [] + + def test_respects_limit(self, tmp_path): + for i in range(50): + (tmp_path / f"file_{i:03d}.txt").touch() + + completions = list(SlashCommandCompleter._path_completions(f"{tmp_path}/", limit=10)) + assert len(completions) == 10 + + def test_case_insensitive_prefix(self, tmp_path): + (tmp_path / "README.md").touch() + + completions = list(SlashCommandCompleter._path_completions(f"{tmp_path}/read")) + names = _display_names(completions) + assert "README.md" in names + + +class TestIntegration: + """Test the completer produces path completions via the prompt_toolkit API.""" + + def test_slash_commands_still_work(self, completer): + doc = Document("/hel", cursor_position=4) + event = MagicMock() + completions = list(completer.get_completions(doc, event)) + names = _display_names(completions) + assert "/help" in names + + def test_path_completion_triggers_on_dot_slash(self, completer, tmp_path): + (tmp_path / "test.py").touch() + old_cwd = os.getcwd() + os.chdir(tmp_path) + try: + doc = Document("edit ./te", cursor_position=9) + event = MagicMock() + completions = list(completer.get_completions(doc, event)) + names = _display_names(completions) + assert "test.py" in names + finally: + os.chdir(old_cwd) + + def test_no_completion_for_plain_words(self, completer): + doc = Document("hello world", cursor_position=11) + event = MagicMock() + completions = list(completer.get_completions(doc, event)) + assert completions == [] + + def test_absolute_path_triggers_completion(self, completer): + doc = Document("check /etc/hos", cursor_position=14) + event = MagicMock() + completions = list(completer.get_completions(doc, event)) + names = _display_names(completions) + # /etc/hosts should exist on Linux + assert any("host" in n.lower() for n in names) + + +class TestFileSizeLabel: + def test_bytes(self, tmp_path): + f = tmp_path / "small.txt" + f.write_text("hi") + assert _file_size_label(str(f)) == "2B" + + def test_kilobytes(self, tmp_path): + f = tmp_path / "medium.txt" + f.write_bytes(b"x" * 2048) + assert _file_size_label(str(f)) == "2K" + + def test_megabytes(self, tmp_path): + f = tmp_path / "large.bin" + f.write_bytes(b"x" * (2 * 1024 * 1024)) + assert _file_size_label(str(f)) == "2.0M" + + def test_nonexistent(self): + assert _file_size_label("/nonexistent_xyz") == ""