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:
michael
2026-07-16 13:11:48 -07:00
parent 72815e34eb
commit d0339bceb5
6 changed files with 210 additions and 94 deletions

View File

@@ -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 ")