fix(rawcli): all hints live in the completion dropdown, not the bottom bar

The memory-location info was being surfaced on the bottom toolbar (input_hint). It belongs in
the autocomplete dropdown — the same popup where identify/READ/… appear. Moved it there and
took it off the bottom bar entirely.

- Bottom toolbar shows only the entry-mode status now; the input_hint machinery is removed.
- Raw hex/binary entry: a matched opcode inserts the RAW BYTE (30, 00110000), labelled with
  the command name — not "READ(". You're building a raw byte string, so accepting a hint keeps
  you in raw bytes.
- New: after a page-command opcode in raw entry, the next byte gets the tag's memory map as raw
  bytes (30 04 -> "04  user memory"), matching what READ( offers. Renders in the entry base.
- Function-call page args still insert 0x-hex addresses (the chosen display).

Verified through the real interactive TUI (pty): typing "30 " pops the memory map in the
completion menu as raw bytes; the old bottom-bar hint string no longer appears anywhere. 1328
green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
michael
2026-07-15 19:09:16 -07:00
parent a3cef180d2
commit 65be53da8c
4 changed files with 87 additions and 123 deletions

View File

@@ -210,9 +210,6 @@ def run(port=None) -> int:
from .completer import RawCompleter
from prompt_toolkit.application import get_app
from .memory import input_hint
def out(text):
# parse the ANSI our formatters emit and render it through prompt_toolkit (renders
# color on a terminal, strips it when piped) — plain print() shows the codes literally
@@ -221,13 +218,8 @@ def run(port=None) -> int:
state = RawSession(device=_connect(port))
def bottom_toolbar():
# live relevance for the current input: what the command does, and where a page lives
try:
text = get_app().current_buffer.text
except Exception:
text = ""
hint = input_hint(state, text)
return hint if hint else f"entry {state.entry_prefix} · Ctrl-/ hex/binary · 'help'"
# just the entry-mode status — all command/page hints live in the completion menu
return f"entry {state.entry_prefix} · Ctrl-/ hex/binary · 'help'"
kb = KeyBindings()
install_key_bindings(kb, state)

View File

@@ -16,7 +16,7 @@ import re
from prompt_toolkit.completion import Completer, Completion
from .parser import CONTROL_VERBS
from .parser import CONTROL_VERBS, parse_token
from .catalog import catalog_for
from .memory import landmark_pages
@@ -49,14 +49,60 @@ class RawCompleter(Completer):
tokens = stripped.split()
at_word = bool(tokens) and not stripped.endswith(" ")
word = tokens[-1] if at_word else ""
# raw byte entry, on the byte after a page-command opcode (30 …): offer the memory map as
# raw bytes, so `30 04` gets the same suggestions as READ( — inserting the raw value
on_second = (len(tokens) == 2 and at_word) or (len(tokens) == 1 and not at_word)
if on_second and self._is_raw_opcode(tokens[0]):
yield from self._raw_page_byte(tokens, at_word)
return
word = tokens[-1] if at_word else ""
completing_first = (" " not in stripped) or (len(tokens) == 1 and at_word)
if completing_first:
yield from self._first_token(word)
elif tokens and tokens[0].lower() == "help":
yield from self._catalog_names(word)
def _is_raw_opcode(self, tok: str) -> bool:
"""True when ``tok`` parses to a single byte that is a page-command opcode (so the next
raw byte is a page argument we can hint)."""
catalog = catalog_for(self._session)
if catalog is None:
return False
try:
b = parse_token(tok, getattr(self._session, "entry_mode", "hex"))
except ValueError:
return False
if len(b) != 1:
return False
tc = catalog.by_opcode(b[0])
return tc is not None and bool(tc.params) and tc.params[0] in ("page", "block", "start")
def _raw_page_byte(self, tokens: list[str], at_word: bool):
"""Offer the tag's memory map for a raw page byte (after a page-command opcode), rendered as
a raw byte in the entry base — ``04`` in hex, ``00000100`` in binary. Accepting inserts the
raw value, keeping you in raw entry (30 04), not the command name."""
partial = tokens[1] if (len(tokens) >= 2 and at_word) else ""
low = partial.lower()
if low.startswith("0x"):
base, body = "hex", low[2:]
elif low.startswith("0b"):
base, body = "bin", low[2:]
elif getattr(self._session, "entry_mode", "hex") == "bin":
base, body = "bin", low
else:
base, body = "hex", low
for page, role in landmark_pages(self._session.transponder):
if base == "bin":
val, forms = f"{page:08b}", (f"{page:08b}", f"{page:b}")
else:
val, forms = f"{page:02X}", (f"{page:02x}", f"{page:x}")
if body and not any(f.startswith(body) for f in forms):
continue
yield Completion(val, start_position=-len(partial),
display=f"{val} {role}", display_meta=role)
def _page_arg(self, cmd_name: str, partial: str):
"""Complete a page/block argument with the identified tag's memory landmarks, rendered in
the active entry mode — ``0x04`` in hex, ``0b00000100`` in binary (every bit visible,
@@ -121,23 +167,23 @@ class RawCompleter(Completer):
return low, getattr(self._session, "entry_mode", "hex")
def _opcode_matches(self, word: str, catalog):
# matching raw hex/binary input to a command by opcode. Accepting inserts the RAW BYTE
# (rendered in the entry base), not the command name — you're building a raw byte string.
digits, base = self._numeric(word)
if not digits:
return
if base == "hex":
valid, render = _HEXDIGITS, (lambda op: f"{op:02x}")
valid, match, value = _HEXDIGITS, (lambda op: f"{op:02x}"), (lambda op: f"{op:02X}")
else:
valid, render = _BINDIGITS, (lambda op: f"{op:08b}")
valid, match, value = _BINDIGITS, (lambda op: f"{op:08b}"), (lambda op: f"{op:08b}")
if any(ch not in valid for ch in digits):
return
low = word.lower()
prefix = word[:len(word) - len(digits)] # keep any 0x/0b the user typed
for name in catalog.names():
if name.lower().startswith(low):
continue # already offered by name
tc = catalog.get(name)
op = _opcode(tc)
if op is not None and render(op).startswith(digits):
yield Completion(f"{name}(", start_position=-len(word),
if op is not None and match(op).startswith(digits):
yield Completion(prefix + value(op), start_position=-len(word),
display=f"{name} (0x{op:02X})",
display_meta=f"opcode 0x{op:02X}{tc.help}")

View File

@@ -1,18 +1,16 @@
"""Per-transponder memory maps + the live relevance hint.
"""Per-transponder memory maps.
The point of rawcli isn't just to send bytes — it's to tell you what they *mean* on the tag in
front of you. ``page_role`` maps a page/block number to its role on the identified transponder
(user memory, CC, AUTH0, PWD, lock bytes, …), and ``input_hint`` turns the current input line into
a one-line relevance hint for the prompt's bottom bar: the command's purpose, and — when you've
typed a page argument — where that page lives.
(user memory, CC, AUTH0, PWD, lock bytes, …), and ``landmark_pages`` lists the notable pages so the
completion menu can surface the memory map as you type. All of this shows up in the completion
dropdown — never in the bottom bar or command output.
Layouts mirror the transponder models' page attributes (``_cfg0_page`` etc.); kept static to
avoid an import-order circular, with a test guarding against drift.
"""
from __future__ import annotations
import re
# Page layout per NTAG21x / Ultralight EV1 IC (user range inclusive; keys are identify's names).
_LAYOUTS = {
"NTAG210": {"user": (0x04, 0x0F), "cfg0": 0x10, "cfg1": 0x11, "pwd": 0x12, "pack": 0x13},
@@ -32,8 +30,6 @@ _ROLES = {
"lock": "dynamic lock bytes",
}
_CALL_HEAD = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*([^,)]*)")
# The pages guaranteed on every Type 2 tag (down to the smallest, NTAG210): UID/CC and user
# memory 0x04-0x0F. Used when only the NTAG/Ultralight *family* is known — GET_VERSION couldn't
# complete on a flaky link, or an original Ultralight has no GET_VERSION. Config pages differ by
@@ -105,64 +101,3 @@ def landmark_pages(transponder: str | None) -> list[tuple[int, str]]:
if key in layout:
out[layout[key]] = _ROLES.get(key, key)
return sorted(out.items())
def _to_int(text: str) -> int | None:
from .parser import parse_arg_int # function-call arg: base 10 by default (0x/0b)
try:
return parse_arg_int(text)
except (ValueError, TypeError):
return None
_ADDR_PARAMS = ("page", "block", "start")
def input_hint(session, text: str) -> str | None:
"""A one-line relevance hint for the current input: the command's purpose, and — for a page/
block argument — where that page lives on the identified transponder. Covers the function-call
form (``READ(4``) and raw byte entry (``30 04``); the memory-location info lives *only* here and
in completion, never in command output."""
from .catalog import catalog_for
stripped = (text or "").strip()
if not stripped:
return None
catalog = catalog_for(session)
if catalog is None:
return None
# function-style call: READ(4 — base-10 arg; hint the command and where that page lives
m = _CALL_HEAD.match(stripped)
if m:
tc = catalog.get(m.group(1))
first = m.group(2).strip()
if tc:
base = f"{tc.usage()}{tc.help}"
if tc.params and tc.params[0] in _ADDR_PARAMS and first:
role = page_role(session.transponder, _to_int(first))
if role:
return f"{base} · {tc.params[0]} {first}{role}"
return base
# a bare catalog command name
tc = catalog.get(stripped)
if tc:
return f"{tc.usage()}{tc.help}"
# raw hex/binary bytes: 30 04 — if the opcode maps to a page command, hint it + the page's role
from .parser import parse_bytes
try:
payload = parse_bytes(stripped, getattr(session, "entry_mode", "hex"))
except ValueError:
payload = b""
if payload:
tc = catalog.by_opcode(payload[0])
if tc:
base = f"{tc.usage()}{tc.help}"
if len(payload) >= 2 and tc.params and tc.params[0] in _ADDR_PARAMS:
role = page_role(session.transponder, payload[1])
if role:
return f"{base} · {tc.params[0]} 0x{payload[1]:02X}{role}"
return base
return None

View File

@@ -534,18 +534,39 @@ class TestCompleter:
cs = self._complete(s, "help GET")
assert any(c.text == "GET_VERSION" for c in cs)
def test_opcode_completion_hex(self):
def _disp(self, comp):
return comp.display[0][1] if comp.display else comp.text
def test_opcode_completion_hex_inserts_raw_byte(self):
# entering raw hex, a matched opcode inserts the RAW BYTE (60), labelled with the command
s = RawSession()
s.protocol, s.transponder = "hf14a", "NTAG213"
cs = self._complete(s, "60") # GET_VERSION opcode
assert any(c.text == "GET_VERSION(" for c in cs)
gv = next(c for c in cs if "GET_VERSION" in self._disp(c))
assert gv.text == "60" # not "GET_VERSION("
# a partial digit completes to the full opcode byte
assert "30" in [c.text for c in self._complete(s, "3")] # READ (0x30)
def test_opcode_completion_binary(self):
def test_opcode_completion_binary_inserts_raw_byte(self):
s = RawSession()
s.protocol, s.transponder = "hf14a", "NTAG213"
s.entry_mode = "bin"
cs = self._complete(s, "01100000") # 0x60 in binary
assert any(c.text == "GET_VERSION(" for c in cs)
gv = next(c for c in cs if "GET_VERSION" in self._disp(c))
assert gv.text == "01100000" # raw binary byte, not the command name
def test_raw_page_byte_completion(self):
# after a page-command opcode in raw entry, the page byte gets the memory map (raw bytes)
s = RawSession()
s.protocol, s.transponder = "hf14a", "NTAG213"
by_text = {c.text: c.display_meta_text for c in self._complete(s, "30 ")}
assert by_text["04"] == "user memory" # inserts raw byte 04, not 0x04
assert by_text["28"] == "dynamic lock bytes"
assert "PWD" in by_text["2B"]
# narrows as you type the byte
assert [c.text for c in self._complete(s, "30 2")] == ["28", "29", "2A", "2B", "2C"]
# a non-page opcode (GET_VERSION) gets no page menu
assert self._complete(s, "60 ") == []
def test_page_arg_lists_memory_map(self):
# typing the page argument suggests the tag's memory landmarks with their roles
@@ -621,32 +642,6 @@ class TestMemoryHint:
assert page_role("NTAG216", 0x04) == "user memory" # different IC, different pages
assert "AUTH0" in page_role("NTAG216", 0xE3)
def test_hint_command_and_page_location(self):
from pm3py.cli.rawcli.memory import input_hint
s = self._sess()
assert "user memory" in input_hint(s, "READ(4)")
assert "PWD" in input_hint(s, "READ(0x2B") # partial input + hex arg
assert "Capability Container" in input_hint(s, "WRITE(3,")
def test_hint_command_only(self):
from pm3py.cli.rawcli.memory import input_hint
s = self._sess()
assert input_hint(s, "GET_VERSION").startswith("GET_VERSION()")
assert input_hint(s, "") is None
def test_hint_needs_transponder(self):
from pm3py.cli.rawcli.memory import input_hint
assert input_hint(RawSession(), "READ(4)") is None
def test_hint_raw_bytes(self):
# raw hex (30 04) gets the location hint too — the opcode maps to READ, byte 1 is the page
from pm3py.cli.rawcli.memory import input_hint
s = self._sess() # NTAG213
h = input_hint(s, "30 04")
assert h.startswith("READ(page)") and "page 0x04 → user memory" in h
assert "PWD" in input_hint(s, "30 2B") # page 0x2B = PWD on a 213
assert input_hint(s, "60").startswith("GET_VERSION") # opcode-only command, no page part
def test_generic_type2_fallback(self):
from pm3py.cli.rawcli.memory import landmark_pages, page_role
# only the family is known (flaky/absent GET_VERSION) -> universal pages still resolve
@@ -659,15 +654,11 @@ class TestMemoryHint:
assert page_role("MIFARE Classic 1K", 4) is None # not Type 2 -> nothing
def test_t5577_block_roles(self):
from pm3py.cli.rawcli.memory import page_role, input_hint
from pm3py.cli.rawcli.memory import page_role
tp = "T5577 (EM4100 20260716FF)"
assert "config" in page_role(tp, 0)
assert "password" in page_role(tp, 7)
assert "data" in page_role(tp, 3)
s = RawSession()
s.protocol, s.transponder = "lf", tp
assert "config" in input_hint(s, "READ(0") # block-role relevance for LF too
assert "password" in input_hint(s, "WRITE(7,")
def test_layouts_match_models(self):
from pm3py.cli.rawcli.memory import _LAYOUTS