feat(cli): add file path autocomplete in the input prompt (#1545)
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
This commit is contained in:
parent
7d2c786acc
commit
2ba219fa4b
2 changed files with 280 additions and 0 deletions
|
|
@ -7,7 +7,9 @@ interactive CLI.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from prompt_toolkit.completion import Completer, Completion
|
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
|
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):
|
def get_completions(self, document, complete_event):
|
||||||
text = document.text_before_cursor
|
text = document.text_before_cursor
|
||||||
if not text.startswith("/"):
|
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
|
return
|
||||||
|
|
||||||
word = text[1:]
|
word = text[1:]
|
||||||
|
|
@ -120,3 +201,18 @@ class SlashCommandCompleter(Completer):
|
||||||
display=cmd,
|
display=cmd,
|
||||||
display_meta=f"⚡ {short_desc}",
|
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"
|
||||||
|
|
|
||||||
184
tests/hermes_cli/test_path_completion.py
Normal file
184
tests/hermes_cli/test_path_completion.py
Normal file
|
|
@ -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") == ""
|
||||||
Loading…
Add table
Add a link
Reference in a new issue