^K in a conversation locks the author of the selected message

In a channel, a DM, or a room, ^K toggles the lock of the contact that wrote
the selected message, so the archive sweep never takes it. The hint says
^K lock or ^K unlock, but only where it fits without dropping another atom,
and the status line confirms what the key did.

On the handhelds, Lock (or Unlock) rides Shift+F2 in a channel and a DM. A
room keeps Log in there, so the Author page that a conversation opens now
carries the lock row, and no other management verb.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Haiku 5.5 committed 2026-10-19 23:18:57 -04:00
1 parent 60a2df727a
commit daa26be5df
9 files changed
+442 -60

No files matched your search

+8 -2
View File
@@ -292,7 +292,8 @@ deliberately, one at a time, and say why in the code.
- **A bare-letter shortcut only where nothing is typed.** A screen that filters, finds or
composes as you type has no free letters, so its verbs are Ctrl chords — one table,
`session._CTRL_LETTER_CHORDS`, each letter the mnemonic of its action where it can be
(`^P` paths, `^U` URL, `^L` locate, `^O` open the author's page; `^G` logs in to a room,
(`^P` paths, `^U` URL, `^L` locate, `^O` open the author's page, `^K` lock the author —
JP, 2026-10-19; `^G` logs in to a room,
JP's pick, 2026-10-11). One verb per letter across screens is the preference, not the
rule (JP, 2026-10-10): a screen that has no use for a letter's verb may give the
letter a verb of its own, so long as nothing between the keyboard and the app takes it —
@@ -853,7 +854,12 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT
on Shift+F2. F4/F5 stay the pager, with `Latest`/`Oldest` behind Shift. A verb new to
one of them goes on the Shift bank; one all three gain goes on the plain bank — and once
that is full, on Shift+F3: `Author` (^O, the author's node page, `ContactPages`) rides
there behind `Map`, its twin about the same author.
there behind `Map`, its twin about the same author. `Lock` (^K, a toggle that reads
`Unlock` on a locked author) is the one exception, by JP's choice (2026-10-19): it sits on
Shift+F2 where that is free (channel, DM), and in a room, where `Log in` holds it, the
`Author` page carries it — a conversation opens that page with its lock row and no other
management verb. Its hint atom goes in only where the hint has room without dropping
another atom.
- **Each handheld deals its own lane.** Everything about the lane that belongs to one
keyboard is a `LaneDeck` (`fkeys.PICOCALC_LYRA_DECK`, `fkeys.CARDPUTER_ZERO_DECK`), named by
`Platform.lane_deck`: which keycodes drive the slots, where the chips sit on the row, how
+112 -12
View File
@@ -113,6 +113,17 @@ def _fit_hint(atoms: list[str], spare: tuple[str, ...] = ()) -> str:
return " · ".join(kept)
#: The long words of a hint atom, and the short words that the same atom has when the hint
#: needs the room. The two forms count as one atom. The short form leaves out a note or a
#: second key, as the hint already does for ^U, ^L, and ^O.
_SHORT_ATOMS = {"Enter reply (@mention)": "Enter reply", "^End/Esc cancel": "Esc cancel"}
def _atom_set(hint: str) -> set[str]:
"""The atoms of a hint, each in its short form, to compare two hints by their atoms."""
return {_SHORT_ATOMS.get(atom, atom) for atom in hint.split(" · ")}
class ChatScreen(Screen):
"""A live conversation: a transcript that scrolls, above a pinned input line.
@@ -125,8 +136,8 @@ class ChatScreen(Screen):
of the selected message. A path belongs to one message, so when no message is
selected, there is nothing to show. ^U shows the links of the selected message as QR
codes, on the share screen. ^L opens the map on the node of the author of the selected
message, when that node has a location. When no message is selected, Esc leaves the
chat.
message, when that node has a location. ^K locks that author against the archive
sweep, or unlocks it. When no message is selected, Esc leaves the chat.
A channel with a send scope shows its region in the title (``#ops · yul``). There, ^R
sends a message again *unscoped*: the selected message, or the newest when no message is
@@ -166,6 +177,13 @@ class ChatScreen(Screen):
page (refer to :meth:`_author_page`). Each of the two halves of F3 is empty when
the screen has nothing to find that node with.
``Lock`` (^K) is the fifth verb about the author, and it is on the Shift half of F2.
It toggles the lock of the author, so it reads ``Unlock`` on a locked author, and
it is lit while the author can have a lock (refer to :meth:`_lock_target`). That
slot is free in a channel and in a DM. In a room, ``Log in`` has it, so there the
user locks from the ``Author`` page, which a conversation opens with its lock row
(JP, 2026-10-19). The slot is empty where MeshTerm cannot store a lock.
The Shift half of F1 follows the two rules of the lane. In a channel with no
resend connected, it is empty, because a channel message is never acknowledged and
there is nothing to retry. In a DM with nothing outstanding, it is only dim.
@@ -205,7 +223,18 @@ class ChatScreen(Screen):
enabled=self._selected is not None and self._paths is not None,
opp_enabled=self._retry_target() is not None,
)
lane[1] = FPair("URL→QR", "url_code", enabled=bool(self._picked_urls()))
lock = self._lock_target()
if self._pages is not None and self._pages.can_lock:
toggle = ("Unlock" if lock is not None and lock[1] else "Lock", "lock")
else:
toggle = ("", "")
lane[1] = FPair(
"URL→QR",
"url_code",
*toggle,
enabled=bool(self._picked_urls()),
opp_enabled=lock is not None,
)
if self._locator is not None or self._pages is not None:
where = ("Map", "locate") if self._locator is not None else ("", "")
who = ("Author", "author_page") if self._pages is not None else ("", "")
@@ -362,7 +391,29 @@ class ChatScreen(Screen):
selected message whose author has a node page (refer to :meth:`_author_page`).
``^R retry failed`` must have something to retry (refer to :meth:`_retry_target`).
The F-key lane uses the same rules to dim its slots.
``^K lock`` (or ``^K unlock``) must have a selected message whose author can have a
lock (refer to :meth:`_lock_target`). It goes in only when there is room for it
(JP, 2026-10-19: "If there's room, put it."): the hint with it must stay in 72 cells
and keep each atom of the hint without it. It can make the words of an atom shorter
(:data:`_SHORT_ATOMS`), as ^U, ^L, and ^O already do. When it does not go in, the
status line still tells what the key did.
"""
hint = self._hint_with("")
lock = self._lock_target()
if lock is not None:
atom = "^K unlock" if lock[1] else "^K lock"
longer = self._hint_with(atom)
if (
cell_len(longer) <= _HINT_CELLS
and atom in longer.split(" · ")
and _atom_set(hint) <= _atom_set(longer)
):
return longer
return hint
def _hint_with(self, lock: str) -> str:
"""The key hint, with the ``lock`` atom after ``^O author`` (or no atom for ``""``)."""
if self._selected is not None:
code = "^U QR" if self._picked_urls() else ""
where = "^L map" if self._locate_target() is not None else ""
@@ -381,6 +432,7 @@ class ChatScreen(Screen):
code,
where,
who,
lock,
"^R resend unscoped",
"↑↓ pick",
"Esc cancel",
@@ -397,6 +449,7 @@ class ChatScreen(Screen):
code,
where,
who,
lock,
"↑↓ pick",
"^End/Esc cancel",
],
@@ -405,11 +458,11 @@ class ChatScreen(Screen):
if cell_len(hint) <= _HINT_CELLS:
return hint
return _fit_hint(
["Enter reply", "^P paths", code, where, who, "↑↓ pick", "Esc cancel"],
["Enter reply", "^P paths", code, where, who, lock, "↑↓ pick", "Esc cancel"],
spare=("↑↓ pick",),
)
return _fit_hint(
["Enter paths", code, where, who, "↑↓ pick", "^End/Esc cancel"],
["Enter paths", code, where, who, lock, "↑↓ pick", "^End/Esc cancel"],
spare=("↑↓ pick",),
)
if self._retry_target() is not None:
@@ -938,10 +991,10 @@ class ChatScreen(Screen):
Ctrl+PgUp/PgDn (day dividers), and the viewport moves with it. Enter on a selected
message starts a reply with an ``@mention`` in a channel, and opens the delivery
paths in a direct chat. In the two types of chat, ^P opens the paths of the
selected message, ^U the QR codes of its links, and ^L the map on its author. The
three do nothing when no message is selected. ^End (or a move past the newest
message) goes back to the compose line. The first Esc clears the selection, and the
second Esc leaves the screen.
selected message, ^U the QR codes of its links, and ^L the map on its author. ^K
locks or unlocks its author. These keys do nothing when no message is selected.
^End (or a move past the newest message) goes back to the compose line. The first
Esc clears the selection, and the second Esc leaves the screen.
"""
if action == "enter":
if self._selected is not None:
@@ -961,6 +1014,8 @@ class ChatScreen(Screen):
self._open_map()
elif action == "author_page":
self._open_author_page()
elif action == "lock":
self._toggle_lock()
elif action == "retry":
if self._is_channel:
self._begin_resend_unscoped()
@@ -1073,11 +1128,56 @@ class ChatScreen(Screen):
message = self._messages[max(0, min(self._selected, len(self._messages) - 1))]
if message.outbound:
return lambda: pages.show(None)
contact = self._author_contact()
return None if contact is None else (lambda: pages.show(contact))
def _author_contact(self) -> Contact | None:
"""The contact that wrote the selected message, or ``None``.
The key of the author finds it (refer to :meth:`_header_key`, the same key that
gives the colour of the sender chip). A message of ours, a message with no sender,
and an author that is not a contact have none.
"""
pages = self._pages
if pages is None or self._selected is None or not self._messages:
return None
message = self._messages[max(0, min(self._selected, len(self._messages) - 1))]
if message.outbound:
return None
sender, _body = self._sender_and_body(message)
if sender == "·":
return None
contact = pages.find(self._header_key(sender, message))
return None if contact is None else (lambda: pages.show(contact))
return pages.find(self._header_key(sender, message))
def _lock_target(self) -> tuple[Contact, bool] | None:
"""The author that ^K locks or unlocks, and whether it is locked now.
``None`` when ^K does nothing: no selected message, a message of ours, an author
that is not a contact, or a contact that cannot have a lock (refer to
:meth:`~meshterm.ui.node_detail_screen.ContactPages.lock_state`). The hint and the
F-key lane ask at each paint, and the store answers from memory.
"""
contact = self._author_contact()
if contact is None or self._pages is None:
return None
locked = self._pages.lock_state(contact)
return None if locked is None else (contact, locked)
def _toggle_lock(self) -> None:
"""Lock the author of the selected message, or unlock it when it is locked (^K).
There is no confirm, because the next ^K reverses it, as the row on the node page
does. The hint atom and the lane chip change to their opposite. The status line also
tells what the key did, because the hint has no room for the atom on each message.
"""
target = self._lock_target()
if target is None or self._pages is None:
return
contact, locked = target
self._pages.set_locked(contact, not locked)
name = contact.name or contact.key_prefix
self._status = f"Unlocked {name}" if locked else f"Locked {name} — no sweep archives it"
self._session.invalidate()
def _open_author_page(self) -> None:
"""Open the node page of the author of the selected message (^O). One page at a time.
@@ -1617,7 +1717,7 @@ async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
scope=scope,
resend_unscoped=resend_unscoped,
locator=await Locator.gather(ctx),
pages=await ContactPages.gather(ctx),
pages=await ContactPages.gather(ctx, self_key=self_key or ""),
self_name=self_name,
self_key=self_key,
)
+109 -42
View File
@@ -43,7 +43,8 @@ screen is the node itself, in full:
where the sweep archives. After a red confirm, it removes this node from the contact
table of the device and from the contacts that MeshTerm remembers for the device.
Then the page closes, because the contact that the page shows does not exist any
more. An archive and a restore also close the page. A lock does not.
more. An archive and a restore also close the page. A lock does not. A page that a
conversation opens has only the lock row of this group.
* **Routes**: the routes on which the device really heard the node arrive. The shared
route graph (:mod:`~meshterm.ui.pathgraph`) draws them from the node to our node, in
the inbound direction of the packets: the contact on the left, our node on the right.
@@ -1252,29 +1253,38 @@ class ContactPages:
twin of :class:`~meshterm.ui.map_screen.Locator`, which ^L uses for the map.
The contacts come from the device one time, when the screen opens.
It is also the ^K key (``lock``) of a conversation: the lock of the author, which
:meth:`lock_state` reads and :meth:`set_locked` writes. The store keeps a lock under
the key of the device, so the pages hold that key too. The state is read from the store
at each paint and never kept here, because the node page can change it too.
"""
def __init__(self, ctx: AppContext, contacts: list[Contact]) -> None:
def __init__(self, ctx: AppContext, contacts: list[Contact], *, self_key: str = "") -> None:
"""Hold the contacts that :meth:`find` searches.
Args:
ctx: The shared application context (it must be in the interactive menu).
contacts: The contacts of the device.
self_key: The public key of the device (hex). The store keeps the locks under
it. With ``""``, no contact can be locked from here.
"""
self._ctx = ctx
self._contacts = contacts
self._self_key = self_key
self._dev_pub = self_key.lower().removeprefix("0x")
# The result of each key that find() got. A screen asks at each paint, and the
# answer for one key does not change while the contacts stay the same.
self._found: dict[str, Contact | None] = {}
@classmethod
async def gather(cls, ctx: AppContext) -> ContactPages:
async def gather(cls, ctx: AppContext, *, self_key: str = "") -> ContactPages:
"""The pages of the contacts that the device has now. With no contacts, nothing is found."""
try:
contacts = await ctx.devstate.contacts()
except Exception: # noqa: BLE001 - with no contacts, ^O opens only our own page
contacts = []
return cls(ctx, list(contacts))
return cls(ctx, list(contacts), self_key=self_key)
def find(self, key: str | None) -> Contact | None:
"""The contact with ``key`` (a full key or a key prefix), or ``None``.
@@ -1294,14 +1304,39 @@ class ContactPages:
)
return self._found[needle]
@property
def can_lock(self) -> bool:
"""Whether a lock can be written from here at all: a store, and the key of the device."""
return getattr(self._ctx, "contact_store", None) is not None and bool(self._dev_pub)
def lock_state(self, contact: Contact | None) -> bool | None:
"""Whether ``contact`` is locked, or ``None`` when it cannot have a lock.
A lock needs the full key of a live contact, as on the node page (refer to
:meth:`~meshterm.core.contact_store.ContactStore.set_locked`). An archived contact
has no lock, because the lock exists to keep a contact out of the archive.
"""
if contact is None or not self.can_lock:
return None
key = (contact.public_key or "").lower().removeprefix("0x")
if len(key) != 64 or _is_archived(self._ctx, contact, self._self_key):
return None
return self._ctx.contact_store.is_locked(self._dev_pub, key)
def set_locked(self, contact: Contact, locked: bool) -> None:
"""Lock or unlock ``contact``. The caller asked :meth:`lock_state` first."""
if self.can_lock:
self._ctx.contact_store.set_locked(self._dev_pub, contact, locked)
async def show(self, contact: Contact | None) -> None:
"""Open the node page of ``contact``, or of our node when it is ``None``.
The page opens to look, not to manage (``manage=False``). The management verbs
(lock, archive, restore, delete) belong to the list that the contact is in, and a
conversation is not that list.
The page opens to look, not to manage (``manage=False``). The verbs that remove a
contact (archive, restore, delete) belong to the list that the contact is in, and a
conversation is not that list. The lock is the exception (``lock=True``): it removes
nothing, and a conversation can already toggle it with ^K (JP, 2026-10-19).
"""
await open_node_detail(self._ctx, contact, manage=False)
await open_node_detail(self._ctx, contact, manage=False, lock=True)
def _same_key(contact_key: str | None, needle: str) -> bool:
@@ -1311,7 +1346,7 @@ def _same_key(contact_key: str | None, needle: str) -> bool:
async def open_node_detail(
ctx: AppContext, contact: Contact | None, *, manage: bool = True
ctx: AppContext, contact: Contact | None, *, manage: bool = True, lock: bool | None = None
) -> bool:
"""Open the Node detail page for a contact (or our node) and run its action loop.
@@ -1343,6 +1378,11 @@ async def open_node_detail(
is really in. They never belong to a page opened from a list that is about to act on
all its rows together.
With ``lock``, a caller that does not manage still lets the page lock and unlock. A
conversation gives ``True`` (refer to :class:`ContactPages`): the lock removes nothing,
and the page that a chat opens is then the way to lock in a room, where the F-key lane
has no free chip for it.
Args:
ctx: The shared application context (the interactive TUI must be running).
contact: The contact to show, or ``None`` for our node (a page of identity and
@@ -1351,6 +1391,8 @@ async def open_node_detail(
manage: Whether to show the contact-management actions (lock, archive, restore,
delete). ``False`` draws none of them, and then the page can only return
``False``.
lock: Whether to show ``Lock contact`` or ``Unlock contact``. ``None`` follows
``manage``.
Returns:
``True`` if the visit ended because the user archived, restored, or deleted the
@@ -1594,48 +1636,32 @@ async def open_node_detail(
info_actions.append(_Action("regions", "🔖", "", "Ask which regions it carries"))
# -- contact management: the last group of the page, and the only actions that end the
# visit. None of them shows when the caller opened the page to look, not to act (refer
# to ``manage``). They never show for our node, or for a contact with no key at
# all, because the write has nothing to use as its address.
archived = manage and _is_archived(ctx, contact, self_key)
manageable = (
manage
and not you
and contact is not None
and bool(contact.public_key or contact.key_prefix)
)
# to ``manage``), except the lock when the caller lets the page lock (refer to
# ``lock``). They never show for our node, or for a contact with no key at all,
# because the write has nothing to use as its address.
may_lock = manage if lock is None else lock
archived = (manage or may_lock) and _is_archived(ctx, contact, self_key)
keyed = not you and contact is not None and bool(contact.public_key or contact.key_prefix)
manageable = manage and keyed
store = getattr(ctx, "contact_store", None)
dev_pub = self_key.lower().removeprefix("0x")
whole_key = contact is not None and len(contact.public_key.lower().removeprefix("0x")) == 64
# A lock is a mark of MeshTerm itself, attached to the full key in the contact store, and
# only a live contact can have one. Its purpose is to keep a contact out of the archive.
lockable = manageable and not archived and whole_key and store is not None and bool(dev_pub)
lockable = (
may_lock and keyed and not archived and whole_key and store is not None and bool(dev_pub)
)
base_actions = list(info_actions)
def management(locked: bool) -> list[_Action]:
"""The action rows of the page, with the management group for this lock state last."""
rows = list(base_actions)
if not manageable:
return rows
# The lock is first. It changes nothing on the device and ends nothing, so it is
# the safest row of the group for the highlight. Then the constructive verb, then
# the destructive verb. Thus a key press by mistake most probably goes to the row
# that the user can undo. Archive and restore are the two halves of one move that
# the user can reverse. They use the ``💾``/``📂`` pair that the lexicon already
# gives to "put away" and "bring back". Only a contact with a full key can be
# written back to the device. Thus a contact with only a key prefix never gets the
# archive, because the archive leaves it with no way back. A locked contact
# never gets the archive at all, and that is the purpose of the lock.
if lockable:
if locked:
rows.append(_Action("unlock", "🔓", "", "Unlock contact"))
else:
rows.append(_Action("lock", "🔒", "", "Lock contact"))
if archived:
rows.append(_Action("restore", "📂", "ok", "Restore to device"))
elif whole_key and not locked:
rows.append(_Action("archive", "💾", "", "Archive contact"))
rows.append(_Action("remove", "🗑", "err", "Delete contact…"))
return rows
return base_actions + _management_rows(
locked=locked,
lockable=lockable,
manageable=manageable,
archived=archived,
whole_key=whole_key,
)
locked = bool(lockable and store.is_locked(dev_pub, contact.public_key))
info_actions = management(locked)
@@ -1747,6 +1773,47 @@ async def open_node_detail(
return contact_removed
def _management_rows(
*, locked: bool, lockable: bool, manageable: bool, archived: bool, whole_key: bool
) -> list[_Action]:
"""The contact-management group of the node page, for one lock state.
The lock is first. It changes nothing on the device and ends nothing, so it is the
safest row of the group for the highlight. Then the constructive verb, then the
destructive verb. Thus a key press by mistake most probably goes to the row that the
user can undo. Archive and restore are the two halves of one move that the user can
reverse. They use the ``💾``/``📂`` pair that the lexicon already gives to "put away"
and "bring back". Only a contact with a full key can be written back to the device.
Thus a contact with only a key prefix never gets the archive, because the archive
leaves it with no way back. A locked contact never gets the archive at all, and that
is the purpose of the lock.
A page that may lock but not manage (a page that a conversation opens) has only the
lock row.
Args:
locked: Whether the contact is locked now.
lockable: Whether the page shows the lock row.
manageable: Whether the page shows archive, restore, and delete.
archived: Whether the contact is archived. Then restore takes the place of archive.
whole_key: Whether the full key of the contact is known, which an archive must have.
"""
rows: list[_Action] = []
if lockable:
if locked:
rows.append(_Action("unlock", "🔓", "", "Unlock contact"))
else:
rows.append(_Action("lock", "🔒", "", "Lock contact"))
if not manageable:
return rows
if archived:
rows.append(_Action("restore", "📂", "ok", "Restore to device"))
elif whole_key and not locked:
rows.append(_Action("archive", "💾", "", "Archive contact"))
rows.append(_Action("remove", "🗑", "err", "Delete contact…"))
return rows
def _is_archived(ctx: AppContext, contact: Contact | None, self_key: str) -> bool:
"""Whether the sweep removed this contact from the device, and MeshTerm keeps it.
+1 -1
View File
@@ -454,7 +454,7 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int:
paths=await _make_paths_presenter(ctx, conversation, device),
key_of=trace_runner.make_name_key_resolver(contacts, stored_names, heard=named_at),
locator=await Locator.gather(ctx),
pages=ContactPages(ctx, list(contacts)),
pages=ContactPages(ctx, list(contacts), self_key=self_key or ""),
self_name=self_name,
self_key=self_key,
)
+4
View File
@@ -105,6 +105,10 @@ _CTRL_LETTER_CHORDS: dict[str, str] = {
# ^G logs in to the room that a room screen shows. In the input, ^G is not a special key:
# it is the bell only in output, and no terminal or multiplexer takes it.
"g": "login",
# ^K locks or unlocks the author of the selected message in a conversation (JP,
# 2026-10-19). No terminal or multiplexer takes it, and the raw mode of prompt_toolkit
# has no binding for it.
"k": "lock",
"l": "locate",
# ^O opens the node page of the author of the selected message. The raw mode of
# prompt_toolkit clears IEXTEN, so a terminal on macOS or BSD does not take ^O to
+7 -3
View File
@@ -69,19 +69,23 @@ def recording_locator(markers: list) -> object:
return locator
def recording_pages(contacts: list) -> object:
def recording_pages(contacts: list, *, store: object = None, self_key: str = "") -> object:
"""A real :class:`~meshterm.ui.node_detail_screen.ContactPages` whose pages are a list.
It finds contacts with the rule of the app. Instead of a node page, :meth:`show` appends
the contact that the page would open on to ``shown`` (``None`` is our node).
the contact that the page would open on to ``shown`` (``None`` is our node). With a
contact ``store`` and the key of the device, ^K can lock the contacts too.
"""
from types import SimpleNamespace
from meshterm.ui.node_detail_screen import ContactPages
class _Recording(ContactPages):
async def show(self, contact) -> None: # noqa: ANN001 - a Contact, or None for us
self.shown.append(contact)
pages = _Recording(None, contacts) # type: ignore[arg-type]
ctx = None if store is None else SimpleNamespace(contact_store=store)
pages = _Recording(ctx, contacts, self_key=self_key) # type: ignore[arg-type]
pages.shown = [] # type: ignore[attr-defined]
return pages
+118
View File
@@ -3147,6 +3147,124 @@ def test_a_direct_chat_finds_the_page_of_its_peer() -> None:
assert cell_len(screen.footer_hint) <= 72
def test_k_locks_and_unlocks_the_author_of_the_selected_message(tmp_path: Path) -> None:
"""^K toggles the lock of the author of the selected message (JP, 2026-10-19).
It is the third verb about the author, with ^L and ^O, and it finds the same contact.
The hint and the Shift half of F2 say ``lock`` or ``unlock``, so they also show the
state. The status line tells what the key did. Our own message, and an author that is
not a contact, have nothing to lock.
"""
from meshterm.core.contact_store import ContactStore
from meshterm.ui.tui.session import _CTRL_LETTER_CHORDS
from tests.conftest import recording_pages
assert _CTRL_LETTER_CHORDS["k"] == "lock"
store = ContactStore(tmp_path / "contacts.json")
dev = "cc" * 32
alice = Contact(name="Alice", public_key="a1" * 32, key_prefix="a1" * 6)
keys = {"alice": "a1" * 32, "bob": "b2" * 32}
screen = ChatScreen(
Conversation(label="#public", is_channel=True, channel_idx=0),
[
ChatMessage(text="Alice: hi", is_channel=True, channel_idx=0),
ChatMessage(text="Bob: yo", is_channel=True, channel_idx=0),
ChatMessage(text="hello", outbound=True, is_channel=True, channel_idx=0),
],
send=None,
names={},
session=_StubSession(),
key_of=lambda name: keys.get(name.casefold()),
pages=recording_pages([alice], store=store, self_key=dev), # type: ignore[arg-type]
)
# With no selected message, ^K does nothing, and its chip is dim.
lane = screen.picocalc_lyra_lane
assert (lane[1].opp_label, lane[1].opp_action, lane[1].opp_enabled) == ("Lock", "lock", False)
screen.handle("lock")
assert store.locked_keys(dev) == frozenset()
# Our own message, then Bob, who is not a contact: nothing to lock.
for _ in range(2):
screen.handle("up")
assert "^K" not in screen.footer_hint
assert screen.picocalc_lyra_lane[1].opp_enabled is False
# Alice is a contact. Shift+F2 sends the same action as ^K.
screen.handle("up")
hint = screen.footer_hint
assert "^K lock" in hint and "^O author" in hint and "↑↓ pick" in hint
assert cell_len(hint) <= 72
lane = screen.picocalc_lyra_lane
assert lane[1].opp_enabled is True
assert fkeys.PICOCALC_LYRA_DECK.action_for(lane, 7) == "lock"
screen.handle("lock")
assert store.is_locked(dev, "a1" * 32)
assert "Locked Alice" in screen._status
assert "^K unlock" in screen.footer_hint
assert screen.picocalc_lyra_lane[1].opp_label == "Unlock"
# The same key reverses it.
screen.handle("lock")
assert not store.is_locked(dev, "a1" * 32)
assert screen._status == "Unlocked Alice"
assert "^K lock" in screen.footer_hint
def test_the_lock_atom_never_pushes_another_key_out_of_the_hint(tmp_path: Path) -> None:
"""With a link and a location, the hint has no room for ^K, so the atom stays out.
The other keys stay as they are without it (JP, 2026-10-19: "If there's room, put
it."). The chip on the lane and the key itself still work.
"""
from meshterm.core.contact_store import ContactStore
from tests.conftest import recording_locator, recording_pages
store = ContactStore(tmp_path / "contacts.json")
dev = "cc" * 32
alice = Contact(name="Alice", public_key="a1" * 32, key_prefix="a1" * 6)
def build(pages: object) -> ChatScreen:
screen = ChatScreen(
Conversation(label="#public", is_channel=True, channel_idx=0),
[ChatMessage(text="Alice: see https://meshterm.net", is_channel=True, channel_idx=0)],
send=None,
names={},
session=_StubSession(),
key_of=lambda name: "a1" * 32 if name == "Alice" else None,
locator=recording_locator([_ALICE_AT]), # type: ignore[arg-type]
pages=pages, # type: ignore[arg-type]
)
screen.handle("up")
return screen
without = build(recording_pages([alice])).footer_hint
screen = build(recording_pages([alice], store=store, self_key=dev))
assert "^K" not in screen.footer_hint
assert screen.footer_hint == without
assert screen.picocalc_lyra_lane[1].opp_enabled is True
screen.handle("lock")
assert store.is_locked(dev, "a1" * 32)
def test_a_chat_without_a_lock_store_has_no_lock_chip() -> None:
"""Where MeshTerm cannot store a lock, the Shift half of F2 is empty, not dim."""
from tests.conftest import recording_pages
alice = Contact(name="Alice", public_key="a1" * 32, key_prefix="a1" * 6)
screen = ChatScreen(
Conversation(label="#public", is_channel=True, channel_idx=0),
[ChatMessage(text="Alice: hi", is_channel=True, channel_idx=0)],
send=None,
names={},
session=_StubSession(),
key_of=lambda name: "a1" * 32,
pages=recording_pages([alice]), # type: ignore[arg-type]
)
screen.handle("up")
assert screen.picocalc_lyra_lane[1].opp_label == ""
assert "^K" not in screen.footer_hint
def test_chat_hint_with_all_its_keys_fits_in_72_cells() -> None:
"""The longest hint (a scoped message of ours, with a link, on the map) fits in 72 cells.
+61
View File
@@ -1460,3 +1460,64 @@ def test_routes_legend_steps_aside_on_a_short_frame() -> None:
assert "▲ repeater" in stage(22) # the body of the PicoCalc
assert "▲ repeater" not in stage(11) # the body of the Cardputer
def test_a_page_that_may_only_lock_shows_only_the_lock_row() -> None:
"""The management group of a page that a conversation opens is only Lock or Unlock.
Archive, restore, and delete belong to the list that the contact is in. A lock removes
nothing, so a conversation can offer it (JP, 2026-10-19). A page that manages keeps its
full group, in the same order as before.
"""
from meshterm.ui.node_detail_screen import _management_rows
def keys(**state: bool) -> list[str]:
base = {"locked": False, "lockable": True, "manageable": False, "archived": False}
return [row.key for row in _management_rows(**{**base, **state}, whole_key=True)]
assert keys() == ["lock"]
assert keys(locked=True) == ["unlock"]
assert keys(lockable=False) == []
assert keys(manageable=True) == ["lock", "archive", "remove"]
assert keys(manageable=True, locked=True) == ["unlock", "remove"]
assert keys(manageable=True, lockable=False, archived=True) == ["restore", "remove"]
async def test_a_conversation_opens_the_author_page_with_its_lock_row(monkeypatch) -> None: # noqa: ANN001
"""^O in a conversation opens the page to look, with the lock as its only verb."""
import meshterm.ui.node_detail_screen as nd
calls: list[tuple] = []
async def _fake(ctx, contact, *, manage=True, lock=None): # noqa: ANN001, ANN202
calls.append((contact, manage, lock))
return False
monkeypatch.setattr(nd, "open_node_detail", _fake)
await nd.ContactPages(None, []).show(None) # type: ignore[arg-type]
assert calls == [(None, False, True)]
def test_contact_pages_read_and_write_the_lock_of_a_live_contact(tmp_path) -> None: # noqa: ANN001
"""The pages lock a contact with a full key, and nothing without a store or a device key."""
from meshterm.core.contact_store import ContactStore
from meshterm.ui.node_detail_screen import ContactPages
store = ContactStore(tmp_path / "contacts.json")
dev = "cc" * 32
alice = Contact(name="Alice", public_key="a1" * 32, key_prefix="a1" * 6)
prefix_only = Contact(name="Bob", public_key="", key_prefix="b2" * 6)
pages = ContactPages(SimpleNamespace(contact_store=store), [alice], self_key=dev) # type: ignore[arg-type]
assert pages.can_lock
assert pages.lock_state(alice) is False
pages.set_locked(alice, True)
assert pages.lock_state(alice) is True and store.is_locked(dev, "a1" * 32)
assert pages.lock_state(prefix_only) is None
# An archived contact has no lock: the lock exists to keep a contact out of the archive.
store.set_locked(dev, alice, False)
store.archive(dev, alice, when=1_700_000_000)
assert pages.lock_state(alice) is None
assert ContactPages(SimpleNamespace(contact_store=store), [alice]).can_lock is False # type: ignore[arg-type]
assert ContactPages(None, [alice], self_key=dev).can_lock is False # type: ignore[arg-type]
+22
View File
@@ -924,6 +924,28 @@ async def test_o_opens_the_node_page_of_the_author_of_a_post() -> None:
assert screen.picocalc_lyra_lane[2].opp_label == "Author"
def test_k_locks_the_author_of_a_post_and_the_lane_keeps_log_in(tmp_path) -> None: # noqa: ANN001
"""^K locks the contact that signed the selected post, as in a channel and a DM.
In a room, Shift+F2 is ``Log in``, so the lane has no ``Lock`` chip there. On a
handheld, the user locks from the ``Author`` page, which a conversation opens with its
lock row (JP, 2026-10-19).
"""
from meshterm.core.contact_store import ContactStore
from tests.conftest import recording_pages
store = ContactStore(tmp_path / "contacts.json")
dev = "cc" * 32
alice = Contact(name="Alice", public_key=ALICE_KEY + "0" * 56, key_prefix=ALICE_KEY + "0000")
screen, _ = _view(_board(), pages=recording_pages([alice], store=store, self_key=dev))
for _ in range(4):
screen.handle("up") # the oldest post, by Alice
screen.handle("lock")
assert store.is_locked(dev, alice.public_key)
lane = screen.picocalc_lyra_lane
assert (lane[1].opp_label, lane[1].opp_action) == ("Log in", "login")
def test_the_login_key_is_g_and_o_opens_the_author_page() -> None:
"""^G logs in to a room, and ^O opens the page of the author of a message (JP, 2026-10-11)."""
from meshterm.ui.tui.session import _CTRL_LETTER_CHORDS