diff --git a/src/prompt_toolkit/key_binding/bindings/auto_suggest.py b/src/prompt_toolkit/key_binding/bindings/auto_suggest.py index b487f14ff..6ee6716c4 100644 --- a/src/prompt_toolkit/key_binding/bindings/auto_suggest.py +++ b/src/prompt_toolkit/key_binding/bindings/auto_suggest.py @@ -7,7 +7,7 @@ import re from prompt_toolkit.application.current import get_app -from prompt_toolkit.filters import Condition, emacs_mode +from prompt_toolkit.filters import Condition, emacs_mode, vi_navigation_mode from prompt_toolkit.key_binding.key_bindings import KeyBindings from prompt_toolkit.key_binding.key_processor import KeyPressEvent @@ -38,9 +38,25 @@ def suggestion_available() -> bool: and app.current_buffer.document.is_cursor_at_the_end ) + @Condition + def suggestion_available_vi_navigation() -> bool: + # In Vi navigation mode, the cursor can never move past the last + # character (unlike insert mode), so `is_cursor_at_the_end` can + # never be true for a non-empty buffer. Being on the last character + # is the navigation-mode equivalent of "at the end" here. + app = get_app() + buffer = app.current_buffer + document = buffer.document + return ( + buffer.suggestion is not None + and len(buffer.suggestion.text) > 0 + and document.cursor_position >= len(document.text) - 1 + ) + @handle("c-f", filter=suggestion_available) @handle("c-e", filter=suggestion_available) @handle("right", filter=suggestion_available) + @handle("l", filter=suggestion_available_vi_navigation & vi_navigation_mode) def _accept(event: E) -> None: """ Accept suggestion. @@ -49,6 +65,11 @@ def _accept(event: E) -> None: suggestion = b.suggestion if suggestion: + # In Vi navigation mode the cursor sits *on* the last character + # rather than past it, so move to the true end before inserting, + # otherwise the suggestion would be spliced in before that + # character instead of appended. + b.cursor_position = len(b.text) b.insert_text(suggestion.text) @handle("escape", "f", filter=suggestion_available & emacs_mode) diff --git a/tests/test_cli.py b/tests/test_cli.py index a876f2993..0ea48bf33 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,10 +5,12 @@ from __future__ import annotations +import asyncio from functools import partial import pytest +from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.clipboard import ClipboardData, InMemoryClipboard from prompt_toolkit.enums import EditingMode from prompt_toolkit.filters import ViInsertMode @@ -37,6 +39,7 @@ def _feed_cli_with_input( multiline=False, check_line_ending=True, key_bindings=None, + auto_suggest=None, ): """ Create a Prompt, feed it with the given user input and return the CLI @@ -58,6 +61,7 @@ def _feed_cli_with_input( multiline=multiline, clipboard=clipboard, key_bindings=key_bindings, + auto_suggest=auto_suggest, ) _ = session.prompt() @@ -607,6 +611,47 @@ def test_vi_cursor_movements(): assert result.text == "heXlo" +def test_vi_accept_suggestion_with_l(): + """ + In Vi navigation mode, 'l' should accept an available auto-suggestion, + the same way the right arrow key already does (fish-shell style). + + This can't use `_feed_cli_with_input`: auto-suggestions are computed by + a background asyncio task scheduled on text changes, so the key(s) that + accept the suggestion have to be sent as a separate write, after + yielding to the event loop, or the suggestion never gets computed in + time. + """ + + async def run(after_escape): + history = InMemoryHistory() + history.append_string("hello world") + + with create_pipe_input() as inp: + session = PromptSession( + input=inp, + output=DummyOutput(), + editing_mode=EditingMode.VI, + history=history, + auto_suggest=AutoSuggestFromHistory(), + ) + task = asyncio.ensure_future(session.app.run_async()) + inp.send_text("hello") + await asyncio.sleep(0.05) # Let the suggestion get computed. + inp.send_text("\x1b" + after_escape + "\r") + return await asyncio.wait_for(task, timeout=2) + + # In navigation mode, the cursor sits *on* the last character (unlike + # insert mode), so a single "l" both counts as "at the end" and accepts + # the suggestion. + assert asyncio.run(run("l")) == "hello world" + + # Away from the last character (here, moved to the start with "0"), + # "l" keeps its normal meaning of moving the cursor one position right, + # even though a suggestion is available. + assert asyncio.run(run("0l")) == "hello" + + def test_vi_operators(): feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI)