fix(clientcli): right-arrow help in the dropdown, selection-restoring LEFT, delete refresh
The right-arrow help never worked for subcommand completions and rendered into
the bottom toolbar (unreadable). Rework it to present in the completion dropdown
— the same two-column form as normal completions — and fix the surrounding
keyboard interactions, all verified by driving the real app through a terminal
emulator (not just unit tests).
- Help now renders as dropdown rows (hints.help_rows), never the bottom bar. The
completer yields the target command's name/usage/options/notes as non-inserting
Completion("") rows while help is open; the bottom bar keeps only the one-line
hint.
- resolve_target: strip the trailing space subcommand completions carry, and don't
re-append a token the menu already inserted into the buffer — both silently made
the panel resolve to nothing.
- RIGHT captures the highlighted node + buffer/complete_state; LEFT restores the
EXACT pre-RIGHT selection. Detach the menu with complete_state=None rather than
cancel_completion(), which calls go_to_completion(None) and wipes the highlighted
index (this is what made Tab commit the wrong command after a RIGHT/LEFT trip).
- Rebind backspace/delete/C-w/C-u to re-run completion: complete_while_typing only
fires on insert, so deleting characters left the dropdown stale/blank.
Tests updated to the real live states (trailing-space completions, post-insertion
buffer), plus guards pinning the detach-not-cancel invariant and the delete-key
rebinding.
This commit is contained in:
@@ -15,7 +15,7 @@ from pm3py.cli.main import build_parser, main
|
||||
from pm3py.cli.clientcli.tree import CommandTree, load_command_tree, parse_option
|
||||
from pm3py.cli.clientcli.session import ClientSession
|
||||
from pm3py.cli.clientcli.completer import ClientCompleter
|
||||
from pm3py.cli.clientcli.hints import one_line_hint, help_panel, panel_for, resolve_target
|
||||
from pm3py.cli.clientcli.hints import one_line_hint, help_rows, resolve_target
|
||||
from pm3py.cli.clientcli.keys import open_help, close_help, install_key_bindings, apply_tab
|
||||
from pm3py.cli.clientcli.driver import (
|
||||
strip_ansi, _extract_output, PROMPT_RE, resolve_client,
|
||||
@@ -189,24 +189,36 @@ class TestHints:
|
||||
assert one_line_hint(_tree(), "zz") is None
|
||||
assert one_line_hint(_tree(), "") is None
|
||||
|
||||
def test_help_panel_multiline(self):
|
||||
panel = _plain(help_panel(_tree().entry_of(["hf", "mf", "rdbl"])))
|
||||
assert "hf mf rdbl" in panel
|
||||
assert "usage:" in panel
|
||||
assert "--blk" in panel and "--key <hex>" in panel
|
||||
assert "This help" not in panel # -h/--help is skipped
|
||||
assert "hf mf rdbl --blk 0" in panel # a note
|
||||
def test_help_rows_leaf(self):
|
||||
# a leaf's help as dropdown rows: (display, meta) — name, usage, each option, each note
|
||||
rows = help_rows(_tree().node_at(["hf", "mf", "rdbl"]))
|
||||
displays = [d for d, _ in rows]
|
||||
metas = [m for _, m in rows]
|
||||
assert ("hf mf rdbl", "Read MIFARE Classic block") == rows[0]
|
||||
assert any("--blk" in d for d in displays)
|
||||
assert any("--key <hex>" in d for d in displays)
|
||||
assert not any("This help" in d for d in displays) # -h/--help is skipped
|
||||
assert "usage" in metas # the usage row
|
||||
assert "hf mf rdbl --blk 0" in displays # a note row
|
||||
|
||||
def test_panel_for_leaf(self):
|
||||
node = _tree().node_at(["hf", "mf", "rdbl"])
|
||||
assert panel_for(node) == help_panel(node.entry)
|
||||
def test_help_rows_non_inserting_and_readable(self):
|
||||
# every row carries display + display_meta (the two-column dropdown form)
|
||||
rows = help_rows(_tree().node_at(["hf", "mf", "rdbl"]))
|
||||
assert all(isinstance(d, str) and d for d, _ in rows)
|
||||
assert all(isinstance(m, str) for _, m in rows)
|
||||
|
||||
def test_panel_for_interior(self):
|
||||
panel = _plain(panel_for(_tree().node_at(["hf", "mf"])))
|
||||
assert "2 subcommands" in panel and "rdbl" in panel and "wrbl" in panel
|
||||
def test_help_rows_interior(self):
|
||||
rows = help_rows(_tree().node_at(["hf", "mf"]))
|
||||
displays = [d for d, _ in rows]
|
||||
assert displays[0] == "mf" # header row
|
||||
assert "rdbl" in displays and "wrbl" in displays
|
||||
|
||||
def test_help_rows_none(self):
|
||||
assert help_rows(None) == []
|
||||
|
||||
def test_resolve_target_subcommand(self):
|
||||
cur = SimpleNamespace(text="rdbl")
|
||||
# real subcommand completions carry a trailing space (Tab-descend mechanism)
|
||||
cur = SimpleNamespace(text="rdbl ")
|
||||
node = resolve_target(_tree(), "hf mf ", cur)
|
||||
assert node is not None and node.token == "rdbl" and node.is_leaf
|
||||
|
||||
@@ -216,30 +228,62 @@ class TestHints:
|
||||
assert node is not None and node.token == "rdbl"
|
||||
|
||||
def test_resolve_target_interior(self):
|
||||
cur = SimpleNamespace(text="mf")
|
||||
cur = SimpleNamespace(text="mf ")
|
||||
node = resolve_target(_tree(), "hf ", cur)
|
||||
assert node is not None and node.token == "mf" and not node.is_leaf
|
||||
|
||||
def test_resolve_target_after_menu_insertion(self):
|
||||
# arrowing onto a completion inserts it into the buffer (go_to_completion), so the
|
||||
# buffer already ends with the highlighted token — it must not be appended twice
|
||||
cur = SimpleNamespace(text="rdbl ")
|
||||
node = resolve_target(_tree(), "hf mf rdbl ", cur)
|
||||
assert node is not None and node.token == "rdbl" and node.is_leaf
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- keys
|
||||
|
||||
class TestKeys:
|
||||
def test_open_help_requires_selection(self):
|
||||
def test_open_help_requires_a_resolved_node(self):
|
||||
s = _session()
|
||||
assert open_help(s, has_selection=False) is False
|
||||
assert s.help_open is False
|
||||
assert open_help(s, has_selection=True) is True
|
||||
assert s.help_open is True
|
||||
assert open_help(s, None) is False # nothing highlighted → no help
|
||||
assert s.help_open is False and s.help_target is None
|
||||
node = s.tree.node_at(["hf", "mf", "rdbl"])
|
||||
assert open_help(s, node) is True
|
||||
assert s.help_open is True and s.help_target is node
|
||||
|
||||
def test_close_help(self):
|
||||
s = _session()
|
||||
s.help_open = True
|
||||
s.help_target = object()
|
||||
assert close_help(s) is False
|
||||
assert s.help_open is False
|
||||
assert s.help_open is False and s.help_target is None
|
||||
|
||||
def test_completer_yields_help_rows_when_open(self):
|
||||
# while help_open, the dropdown renders the target's help as non-inserting completions
|
||||
from prompt_toolkit.completion import CompleteEvent
|
||||
from prompt_toolkit.document import Document
|
||||
s = _session()
|
||||
s.help_open = True
|
||||
s.help_target = s.tree.node_at(["hf", "mf", "rdbl"])
|
||||
comps = list(ClientCompleter(s).get_completions(Document("hf mf rdbl ", 11), CompleteEvent()))
|
||||
assert comps and all(c.text == "" for c in comps) # selecting one inserts nothing
|
||||
shown = [_plain(c.display[0][1]) for c in comps]
|
||||
assert any("--blk" in d for d in shown)
|
||||
|
||||
def test_install_does_not_raise(self):
|
||||
install_key_bindings(KeyBindings(), _session())
|
||||
|
||||
def test_delete_keys_rebound_to_refresh_dropdown(self):
|
||||
# complete_while_typing only completes on insert, so the deletion keys must be rebound to
|
||||
# re-run completion — else the dropdown goes stale/blank when you backspace.
|
||||
kb = KeyBindings()
|
||||
install_key_bindings(kb, _session())
|
||||
bound = {str(k) for b in kb.bindings for k in b.keys}
|
||||
assert "Keys.ControlH" in bound # backspace
|
||||
assert "Keys.Delete" in bound # delete
|
||||
assert "Keys.ControlW" in bound # delete word
|
||||
assert "Keys.ControlU" in bound # delete to line start
|
||||
|
||||
def _buffer_with_menu(self, text):
|
||||
from prompt_toolkit.buffer import Buffer, CompletionState
|
||||
from prompt_toolkit.completion import CompleteEvent
|
||||
@@ -252,6 +296,32 @@ class TestKeys:
|
||||
completions=comps, complete_index=None)
|
||||
return buff
|
||||
|
||||
def test_left_restore_preserves_highlighted_index(self):
|
||||
# The RIGHT→LEFT round-trip must return the user to the completion they had highlighted.
|
||||
# Mechanism: help mode DETACHES the menu (complete_state = None) and re-attaches the same
|
||||
# object on LEFT. It must NOT use cancel_completion(), which calls go_to_completion(None)
|
||||
# and wipes the highlighted index — the bug that made Tab commit the wrong command.
|
||||
buff = self._buffer_with_menu("hf mf ")
|
||||
buff.go_to_completion(1) # DOWN onto the 2nd child (wrbl)
|
||||
doc, state, idx = buff.document, buff.complete_state, buff.complete_state.complete_index
|
||||
assert idx == 1 and buff.text == "hf mf wrbl "
|
||||
|
||||
buff.complete_state = None # RIGHT: detach without mutating `state`
|
||||
assert state.complete_index == idx # ← the invariant; cancel_completion breaks it
|
||||
|
||||
buff.document = doc # LEFT: restore exactly
|
||||
buff.complete_state = state
|
||||
assert buff.text == "hf mf wrbl " and buff.complete_state.complete_index == 1
|
||||
|
||||
def test_cancel_completion_would_lose_the_index(self):
|
||||
# Guard the contrast: prove cancel_completion() is the wrong tool here, so nobody swaps it
|
||||
# back in "to simplify".
|
||||
buff = self._buffer_with_menu("hf mf ")
|
||||
buff.go_to_completion(1)
|
||||
state = buff.complete_state
|
||||
buff.cancel_completion()
|
||||
assert state.complete_index is None # cancel wiped it — must not be used for RIGHT
|
||||
|
||||
def test_tab_commits_first_completion(self):
|
||||
# with nothing highlighted, Tab commits the FIRST completion (+ its trailing space)
|
||||
buff = self._buffer_with_menu("hf ")
|
||||
|
||||
Reference in New Issue
Block a user