diff --git a/pm3py/cli/clientcli/app.py b/pm3py/cli/clientcli/app.py index ab55ffe..71e2c23 100644 --- a/pm3py/cli/clientcli/app.py +++ b/pm3py/cli/clientcli/app.py @@ -1,6 +1,7 @@ """clientcli interactive loop — a prompt_toolkit REPL that drives the stock ``proxmark3`` client -with command-tree autocompletion, live hints, and a right-arrow help toggle. Mirrors the -:mod:`pm3py.cli.rawcli.app` skeleton; the testable logic lives in the sibling modules.""" +with command-tree autocompletion, live hints, and right-arrow help shown in the completion +dropdown. Mirrors the :mod:`pm3py.cli.rawcli.app` skeleton; the testable logic lives in the +sibling modules.""" from __future__ import annotations import sys @@ -10,9 +11,9 @@ from .driver import make_driver, DriverError from .session import ClientSession from .completer import ClientCompleter from .keys import install_key_bindings -from .hints import one_line_hint, resolve_target, panel_for +from .hints import one_line_hint -_DEFAULT_HINT = "type a pm3 command · Tab to complete · → toggles help on a selection · 'quit'" +_DEFAULT_HINT = "type a pm3 command · Tab to complete · → help on a selection · 'quit'" def dispatch(state, line, out=print) -> bool: @@ -60,15 +61,9 @@ def run(port=None, commands=None, client="proxmark3", driver="auto") -> int: state = ClientSession(dev, tree) def bottom_toolbar(): - # live relevance for the current input; the expanded help panel when the toggle is open + # only ever the one-line relevance hint — the full help lives in the completion dropdown try: - buf = get_app().current_buffer - cur = buf.complete_state.current_completion if buf.complete_state else None - if state.help_open and cur is not None: - panel = panel_for(resolve_target(tree, buf.text, cur)) - if panel: - return ANSI(panel) - hint = one_line_hint(tree, buf.text) + hint = one_line_hint(tree, get_app().current_buffer.text) except Exception: hint = None return ANSI(hint) if hint else _DEFAULT_HINT @@ -92,6 +87,7 @@ def run(port=None, commands=None, client="proxmark3", driver="auto") -> int: with patch_stdout(): while True: state.help_open = False # each new line starts with help collapsed + state.help_target = None try: line = session.prompt(message) except (EOFError, KeyboardInterrupt): diff --git a/pm3py/cli/clientcli/completer.py b/pm3py/cli/clientcli/completer.py index 7e0d2b6..c6dee7a 100644 --- a/pm3py/cli/clientcli/completer.py +++ b/pm3py/cli/clientcli/completer.py @@ -5,6 +5,7 @@ from __future__ import annotations from prompt_toolkit.completion import Completer, Completion +from .hints import help_rows from .tree import parse_option @@ -17,6 +18,13 @@ class ClientCompleter(Completer): self._session = session def get_completions(self, document, complete_event): + # help mode (RIGHT on a selection): the dropdown becomes the command's help — the same + # two-column rows as normal completions, but non-inserting (LEFT restores completion). + if self._session.help_open: + for disp, meta in help_rows(self._session.help_target): + yield Completion("", start_position=0, display=disp, display_meta=meta) + return + tree = self._session.tree text = document.text_before_cursor tokens = text.split() diff --git a/pm3py/cli/clientcli/hints.py b/pm3py/cli/clientcli/hints.py index bef0833..55d89ed 100644 --- a/pm3py/cli/clientcli/hints.py +++ b/pm3py/cli/clientcli/hints.py @@ -1,21 +1,14 @@ -"""Bottom-toolbar hints for clientcli. +"""Hints for clientcli, all driven by the command tree. -Two channels, both driven by the command tree (models :func:`pm3py.cli.rawcli.memory.input_hint`): - -- :func:`one_line_hint` — a one-line relevance hint for the current input (always on). -- :func:`help_panel` / :func:`panel_for` — the multi-line help shown when the help toggle is open; +- :func:`one_line_hint` — the one-line relevance hint on the bottom bar for the current input. +- :func:`help_rows` — the full help of a command as ``(display, meta)`` rows for the *completion + dropdown* (never the bottom bar — the dropdown is where all hints live, per rawcli 65be53d); :func:`resolve_target` maps the *highlighted* completion back to the node whose help to show. """ from __future__ import annotations from .tree import parse_option -# ANSI (same palette as the trace / breadcrumb) -_DIM = "\033[2m" -_BOLD = "\033[1m" -_CYAN = "\033[36m" -_RESET = "\033[0m" - def _skip(flags) -> bool: return "--help" in flags or "-h" in flags @@ -38,41 +31,31 @@ def one_line_hint(tree, text): return f"{' '.join(used)} — {len(node.children)} subcommands" -def help_panel(entry) -> str: - """The full multi-line help for a leaf command: description, usage, options, and notes.""" - if not entry: - return "" - out = [f"{_BOLD}{entry['command']}{_RESET} — {entry.get('description', '')}", - f"{_DIM}usage:{_RESET} {entry.get('usage', '')}"] - rows = [] - for opt in entry.get("options", []): - flags, meta, desc = parse_option(opt) - if not flags or _skip(flags): - continue - rows.append((", ".join(flags) + (f" {meta}" if meta else ""), desc)) - if rows: - w = min(max(len(h) for h, _ in rows), 28) - out.append(f"{_DIM}options:{_RESET}") - out += [f" {_CYAN}{h:<{w}}{_RESET} {d}" for h, d in rows] - notes = entry.get("notes", []) - if notes: - out.append(f"{_DIM}notes:{_RESET}") - out += [f" {_DIM}{n}{_RESET}" for n in notes] - return "\n".join(out) - - -def panel_for(node) -> str | None: - """The help panel for a highlighted node — a leaf's full help, or an interior node's child - list. ``None`` when there is nothing to show.""" +def help_rows(node): + """The full help of a highlighted ``node`` as ``(display, meta)`` rows for the completion + dropdown — a leaf's name/usage/options/notes, or an interior node's subcommands. Presented in + the same two-column form the normal completions use. ``[]`` when there is nothing to show.""" if node is None: - return None + return [] if node.is_leaf: - return help_panel(node.entry) + e = node.entry + rows = [(e["command"], e.get("description", ""))] + if e.get("usage"): + rows.append((e["usage"], "usage")) + for opt in e.get("options", []): + flags, meta, desc = parse_option(opt) + if not flags or _skip(flags): + continue + rows.append(((", ".join(flags) + (f" {meta}" if meta else "")).strip(), desc)) + rows += [(n, "example") for n in e.get("notes", [])] + return rows kids = sorted(node.children) - if not kids: - return None - head = f"{_BOLD}{node.token or '(commands)'}{_RESET} — {len(kids)} subcommands" - return head + "\n" + f"{_DIM}{', '.join(kids)}{_RESET}" + rows = [(node.token or "(commands)", f"{len(kids)} subcommands")] + for tok in kids: + child = node.children[tok] + meta = child.entry.get("description", "") if child.is_leaf else f"{len(child.children)} subcommands" + rows.append((tok, meta)) + return rows def resolve_target(tree, buffer_text, current_completion): @@ -82,12 +65,17 @@ def resolve_target(tree, buffer_text, current_completion): tokens = (buffer_text or "").split() at_word = bool(tokens) and not (buffer_text or "").endswith(" ") path = tokens[:-1] if at_word else tokens - ctext = getattr(current_completion, "text", "") if current_completion else "" + # subcommand completions carry a trailing space (the Tab-descend mechanism) — strip it or + # node_at's exact-token lookup misses and the help panel silently never shows + ctext = (getattr(current_completion, "text", "") if current_completion else "").strip() if ctext.startswith("-"): for i in range(len(path), -1, -1): node = tree.node_at(path[:i]) if node is not None and node.is_leaf: return node return None - full = path + [ctext] if ctext else path - return tree.node_at(full) + # menu navigation has already inserted the highlighted completion into the buffer + # (Buffer.go_to_completion), so the path may already end with ctext — don't append it twice + if ctext and (not path or path[-1] != ctext): + path = path + [ctext] + return tree.node_at(path) diff --git a/pm3py/cli/clientcli/keys.py b/pm3py/cli/clientcli/keys.py index 72c3c31..e3dfab4 100644 --- a/pm3py/cli/clientcli/keys.py +++ b/pm3py/cli/clientcli/keys.py @@ -1,9 +1,10 @@ """The help-toggle key bindings for clientcli — the centrepiece interaction. -When a completion is highlighted in the menu, RIGHT toggles a full help panel for it in the bottom -toolbar; LEFT (primary) or ESCAPE / Ctrl-g (secondary) collapses it. Only a single ``help_open`` -flag lives on the session — the toolbar recomputes the *target* from the live highlighted completion -each render, so arrowing through the menu re-targets the panel automatically. +When a completion is highlighted in the menu, RIGHT swaps the *dropdown* to the full help of that +command (name, usage, options, notes as rows); LEFT (primary) or ESCAPE / Ctrl-g (secondary) +restores normal completion. The help never touches the bottom bar — the dropdown is where all hints +live. The highlighted node is captured into ``session.help_target`` at the moment RIGHT is pressed, +then the completer renders its help while ``help_open`` is set. The pure ``open_help`` / ``close_help`` helpers carry the state transitions for unit tests; the key wiring is exercised in the live REPL. @@ -11,15 +12,18 @@ wiring is exercised in the live REPL. from __future__ import annotations -def open_help(session, has_selection: bool) -> bool: - """Open help iff a completion is highlighted. Returns the resulting ``help_open`` state.""" - if has_selection: +def open_help(session, node) -> bool: + """Open help iff ``node`` resolved to something. Captures it as ``help_target`` so the completer + can render its help. Returns the resulting ``help_open`` state.""" + if node is not None: + session.help_target = node session.help_open = True return session.help_open def close_help(session) -> bool: session.help_open = False + session.help_target = None return session.help_open @@ -51,24 +55,72 @@ def install_key_bindings(kb, session) -> None: """Wire TAB completion and RIGHT/LEFT/ESCAPE/Ctrl-g help toggling onto ``kb``. Never raises on an unknown key.""" from prompt_toolkit.filters import Condition, completion_is_selected + from prompt_toolkit.key_binding.bindings.named_commands import get_by_name + + from .hints import resolve_target help_is_open = Condition(lambda: session.help_open) + # The exact buffer document + completion menu captured when RIGHT was pressed, so LEFT can put + # the user back on the completion they had highlighted (not a freshly regenerated menu). + saved = {} + # Tab commits the highlighted (or first) completion so it sticks and descends a level; # Shift-Tab keeps prompt_toolkit's default (previous completion). _bind(kb, "tab", lambda event: apply_tab(event.current_buffer)) - def _toggle(event): - session.help_open = not session.help_open + # complete_while_typing only re-runs completion on INSERT, so deleting characters leaves the + # dropdown stale (or blank). Re-run the completer after each deletion so the menu tracks the + # now-shorter input. Wrap the default readline command, then refresh — but not while the help + # panel is up (editing exits help first, handled by the LEFT/close path). + def _delete_then_complete(command_name): + binding = get_by_name(command_name) + + def _handler(event): + binding.handler(event) # the default deletion + if not session.help_open: + event.current_buffer.start_completion(select_first=False) # refresh the dropdown + return _handler + + for _key, _cmd in (("backspace", "backward-delete-char"), + ("delete", "delete-char"), + ("c-w", "unix-word-rubout"), + ("c-u", "unix-line-discard")): + _bind(kb, _key, _delete_then_complete(_cmd)) + + def _open(event): + buff = event.current_buffer + state = buff.complete_state + cur = state.current_completion if state else None + node = resolve_target(session.tree, buff.text, cur) + if not open_help(session, node): # only if it resolved to a real command + return + # Remember exactly where we are. DETACH the menu (complete_state = None) rather than + # cancel_completion() — the latter calls go_to_completion(None), which would mutate this + # very state object and wipe the highlighted index we need to restore. + saved["document"] = buff.document + saved["state"] = state + buff.complete_state = None + buff.start_completion(select_first=False) # regenerate → help rows (completer sees help_open) event.app.invalidate() def _close(event): - session.help_open = False + buff = event.current_buffer + close_help(session) + if saved.get("state") is not None: + # Restore the pre-RIGHT selection exactly. Set the document first (this detaches + # complete_state without firing completion), then re-attach the saved menu — the same + # order Buffer.go_to_completion uses. + buff.document = saved["document"] + buff.complete_state = saved["state"] + saved.clear() + else: + buff.cancel_completion() event.app.invalidate() - # RIGHT toggles help for the highlighted completion. With nothing selected the filter is false, - # so the binding doesn't apply and the default cursor-right still works. - _bind(kb, "right", _toggle, filter=completion_is_selected) + # RIGHT opens help for the highlighted completion. With nothing selected (or help already open) + # the filter is false, so the binding is inert and the default cursor-right still works. + _bind(kb, "right", _open, filter=completion_is_selected & ~help_is_open) # LEFT is the instant back-out; active only while help is open, else default cursor-left. _bind(kb, "left", _close, filter=help_is_open) # ESCAPE also backs out, but it is prompt_toolkit's meta prefix, so a bare escape fires only diff --git a/pm3py/cli/clientcli/session.py b/pm3py/cli/clientcli/session.py index a16865d..4e97b86 100644 --- a/pm3py/cli/clientcli/session.py +++ b/pm3py/cli/clientcli/session.py @@ -14,12 +14,14 @@ _RESET = "\033[0m" class ClientSession: """Mutable clientcli state. ``driver`` runs the stock client, ``tree`` is the loaded command - trie, and ``help_open`` toggles the bottom-toolbar help panel.""" + trie. ``help_open`` swaps the completion dropdown to the help of ``help_target`` (the node + captured when RIGHT was pressed); LEFT clears both back to normal completion.""" def __init__(self, driver: Any, tree: Any): self.driver = driver self.tree = tree self.help_open: bool = False + self.help_target: Any = None @property def prompt_label(self) -> str: diff --git a/tests/test_clientcli.py b/tests/test_clientcli.py index 2f2a83b..abfcb31 100644 --- a/tests/test_clientcli.py +++ b/tests/test_clientcli.py @@ -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 " 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 " 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 ")