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:
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user