Chat picker: companions only, and Del deletes a thread's history

The Direct section now lists only companion nodes (a contact whose
type was never advertised gets the benefit of the doubt) — repeaters,
rooms, and sensors are not DM targets. Pressing Del on a contacted
thread offers the reserved-red Cancel/Delete confirm; on commit the
peer's stored messages are removed (new Repository.delete_chat_history
— channel history and other peers untouched), the unread count clears,
and the re-entered picker shows the row hollow-dotted in the
uncontacted tail. The Del atom rides the existing deletable-row
machinery, with delete_hint now threaded through Ui/TuiUi/session
select so the footer advertises it only on rows it can act on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-07-19 01:21:43 -04:00
1 parent 3fe4e5b4e1
commit 7b689dfd04
5 files changed
+301 -57

No files matched your search

+20
View File
@@ -1533,6 +1533,26 @@ class Repository:
)
self._conn.commit()
def delete_chat_history(self, peer: Optional[str]) -> int:
"""Delete every stored message of one direct conversation.
The chat picker's per-contact history delete: removes only that peer's direct
messages — channel history and every other conversation stay untouched. The
peer matches how :meth:`record_chat_message` stores it (lowercased key prefix).
Args:
peer: The contact's key prefix (the direct conversation's identity).
Returns:
How many messages were deleted.
"""
cur = self._conn.execute(
"DELETE FROM messages WHERE is_channel = 0 AND peer = ?",
((peer or "").lower(),),
)
self._conn.commit()
return int(cur.rowcount or 0)
def recent_chat_messages(
self,
*,
+98 -53
View File
@@ -33,7 +33,7 @@ from ..services.trace_runner import NameKeyResolver, make_name_key_resolver
from ..ui.chat import _MENTION, _split_channel_sender
from ..ui.menus import fit_cells
from ..ui.theme import name_style
from ..ui.tui import Choice, Separator
from ..ui.tui import Choice, DeleteRequest, Separator
from ..ui.widgets import _NODE_GLYPHS, channel_glyph
from .base import Tool, ToolResult, register
@@ -118,68 +118,113 @@ class ChatTool(Tool):
The chosen :class:`~meshterm.core.models.Conversation`, or ``None`` if
cancelled.
"""
# Through the session cache: this picker runs on every Chat open, and its two reads —
# the channel-slot probe and the contacts table — are the two slowest round-trips on a
# companion. Reading them from the device each time is what made opening Chat stall for
# seconds (the cached chat screen behind it never got the chance to help). The cache
# holds channels until the channel editor writes a slot and refreshes contacts in the
# background (see :class:`~meshterm.services.device_state.DeviceState`).
channels = _channels_from_slots(await ctx.devstate.channel_slots())
contacts = await ctx.devstate.contacts()
# A stable snapshot orders the rows (so the list doesn't reshuffle under the cursor),
# while a self-refreshing view feeds each row's live preview (see _LiveLasts).
lasts = ctx.repo.last_chat_messages()
live = _LiveLasts(ctx, seed=lasts)
# Names in previews/mentions resolve back to keys for their hue (the app-wide
# colour rule); a name no contact or stored advert carries stays muted.
key_of = make_name_key_resolver(contacts, ctx.repo.node_names())
while True:
# Through the session cache: this picker runs on every Chat open, and its two
# reads — the channel-slot probe and the contacts table — are the two slowest
# round-trips on a companion. Reading them from the device each time is what made
# opening Chat stall for seconds (the cached chat screen behind it never got the
# chance to help). The cache holds channels until the channel editor writes a
# slot and refreshes contacts in the background (see
# :class:`~meshterm.services.device_state.DeviceState`).
channels = _channels_from_slots(await ctx.devstate.channel_slots())
contacts = await ctx.devstate.contacts()
# Only companion nodes are listed — we don't DM repeaters, rooms, or sensors;
# a contact whose type was never advertised gets the benefit of the doubt.
companions = [c for c in contacts if c.node_type in (NODE_TYPE_CHAT, None)]
# A stable snapshot orders the rows (so the list doesn't reshuffle under the
# cursor), while a self-refreshing view feeds each row's live preview
# (see _LiveLasts).
lasts = ctx.repo.last_chat_messages()
live = _LiveLasts(ctx, seed=lasts)
# Names in previews/mentions resolve back to keys for their hue (the app-wide
# colour rule); a name no contact or stored advert carries stays muted.
key_of = make_name_key_resolver(contacts, ctx.repo.node_names())
items: list = [
Separator(_picker_header()),
Separator("── 📡 Channels ──", style="accent"),
]
for conversation in channels:
items.append(
Choice(title=_row_title(ctx, conversation, live, key_of), value=conversation)
)
items.append(Separator("── 👤 Direct ──", style="accent"))
if contacts:
# List contacts by recency — those with messages first, newest exchange at the
# top — then the never-contacted ones alphabetically (see _recency_key).
direct = [
Conversation(label=c.name, is_channel=False, contact=c) for c in contacts
items: list = [
Separator(_picker_header()),
Separator("── 📡 Channels ──", style="accent"),
]
direct.sort(key=lambda conv: _recency_key(conv, lasts))
for conversation in direct:
for conversation in channels:
items.append(
Choice(
title=_row_title(ctx, conversation, live, key_of),
value=conversation,
)
)
else:
items.append(Separator(" (no contacts yet — receive an advert first)"))
items.append(Separator(" "))
items.append(Choice(title="Back", value="__back__"))
items.append(Separator("── 👤 Direct ──", style="accent"))
if companions:
# List contacts by recency — those with messages first, newest exchange at
# the top — then the never-contacted ones alphabetically (see _recency_key).
direct = [
Conversation(label=c.name, is_channel=False, contact=c)
for c in companions
]
direct.sort(key=lambda conv: _recency_key(conv, lasts))
for conversation in direct:
items.append(
Choice(
title=_row_title(ctx, conversation, live, key_of),
value=conversation,
# Del offers to delete this thread's stored history —
# only where there is history to delete.
deletable=lasts.get(conversation.key) is not None,
)
)
else:
items.append(Separator(" (no contacts yet — receive an advert first)"))
default = next(
(
it.value
for it in items
if isinstance(it, Choice)
and isinstance(it.value, Conversation)
and it.value.key == default_key
),
None,
)
choice = await ctx.ui.select(
"Chat — pick a conversation", items, default=default, wrap=False
)
if choice in (None, "__back__"):
return None
return choice
items.append(Separator(" "))
items.append(Choice(title="Back", value="__back__"))
default = next(
(
it.value
for it in items
if isinstance(it, Choice)
and isinstance(it.value, Conversation)
and it.value.key == default_key
),
None,
)
choice = await ctx.ui.select(
"Chat — pick a conversation",
items,
default=default,
wrap=False,
delete_hint="Del delete history",
)
if isinstance(choice, DeleteRequest):
conversation = choice.value
await self._delete_history(ctx, conversation)
# Re-enter with fresh rows: a deleted thread's dot hollows and the row
# demotes to the uncontacted (alphabetical) tail; keep the cursor on it.
default_key = conversation.key
continue
if choice in (None, "__back__"):
return None
return choice
@staticmethod
async def _delete_history(ctx: AppContext, conversation: Conversation) -> None:
"""Confirm and delete one direct conversation's stored history.
Deleting history is irreversible data loss, so the confirm wears the reserved
red (``destructive``): Cancel on the left, the committing Delete on the right.
On confirm the peer's messages are removed from the database and the thread's
unread count is cleared; the contact itself (a device-side record) is untouched.
"""
if not await ctx.ui.dialog(
f"Delete the chat history with {conversation.label}? Every stored "
"message in this conversation is removed.",
[("Cancel", False), ("Delete", True)],
title="Delete history",
default=1,
destructive=True,
):
return
ctx.repo.delete_chat_history(conversation.peer)
ctx.chat.clear_unread(conversation.key)
# -- CLI --------------------------------------------------------------------
+7 -2
View File
@@ -115,12 +115,14 @@ class Ui:
default: Any = None,
wrap: bool = True,
filterable: bool = True,
delete_hint: str = "",
) -> Any:
"""Prompt the user to choose one item; return its value or ``None`` if cancelled.
``prompt`` draws an instruction inside the popup, above the list. Pass
``filterable=False`` for a short, fixed list so a stray key can't narrow (and
resize) it.
resize) it. ``delete_hint`` (with rows marked deletable) enables the Delete
key's remove flow — see :meth:`~meshterm.ui.tui.session.TuiSession.select`.
"""
raise NotImplementedError
@@ -394,6 +396,7 @@ class PlainUi(Ui):
default: Any = None,
wrap: bool = True,
filterable: bool = True,
delete_hint: str = "",
) -> Any:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
@@ -639,10 +642,12 @@ class TuiUi(Ui):
default: Any = None,
wrap: bool = True,
filterable: bool = True,
delete_hint: str = "",
) -> Any:
"""Delegate to the session's select screen."""
return await self.session.select(
title, items, prompt=prompt, default=default, wrap=wrap, filterable=filterable
title, items, prompt=prompt, default=default, wrap=wrap,
filterable=filterable, delete_hint=delete_hint,
)
async def select_startup(
+7 -2
View File
@@ -362,15 +362,20 @@ class TuiSession:
wrap: bool = True,
filterable: bool = True,
footer_hint: Optional[str] = None,
delete_hint: str = "",
) -> Any:
"""Show a select screen; return the chosen value or ``None`` if cancelled.
``prompt`` draws an instruction inside the box above the list; ``filterable`` and
``footer_hint`` are forwarded for short, fixed lists (a yes-or-no style choice) that
want no type-to-filter and a tailored hint.
want no type-to-filter and a tailored hint. ``delete_hint`` (with rows marked
:attr:`~meshterm.ui.tui.select.Choice.deletable`) surfaces the Delete key's atom
while the highlight sits on such a row; Delete then resolves a
:class:`~meshterm.ui.tui.select.DeleteRequest` the caller unwraps.
"""
kwargs: dict[str, Any] = dict(
prompt=prompt, default=default, wrap=wrap, filterable=filterable
prompt=prompt, default=default, wrap=wrap, filterable=filterable,
delete_hint=delete_hint,
)
if footer_hint is not None:
kwargs["footer_hint"] = footer_hint
+169
View File
@@ -1303,3 +1303,172 @@ async def test_open_chat_sends_through_real_session(tmp_path: Path) -> None:
assert stored[0].outbound is True
finally:
await ctx.aclose()
# -- the picker's companion filter and history delete -------------------------
def test_delete_chat_history_removes_only_that_peer(repo: Repository) -> None:
"""Deleting one direct thread leaves other peers and channel history untouched."""
repo.record_chat_message(ChatMessage(text="a", peer="aa"))
repo.record_chat_message(ChatMessage(text="b", peer="bb"))
repo.record_chat_message(ChatMessage(text="c", is_channel=True, channel_id="c0"))
assert repo.delete_chat_history("aa") == 1
assert repo.recent_chat_messages(is_channel=False, peer="aa") == []
assert [m.text for m in repo.recent_chat_messages(is_channel=False, peer="bb")] == ["b"]
assert [
m.text for m in repo.recent_chat_messages(is_channel=True, channel_id="c0")
] == ["c"]
class _PickerDevstate:
def __init__(self, contacts) -> None:
self._contacts = contacts
async def channel_slots(self):
return []
async def contacts(self):
return list(self._contacts)
class _PickerChat(_FakeChat):
def __init__(self, unread=None) -> None:
super().__init__(unread or {})
self.cleared: list[str] = []
def clear_unread(self, key: str) -> None:
self.cleared.append(key)
async def test_picker_lists_companions_only(repo: Repository) -> None:
"""Only companion (and type-unknown) contacts appear in Direct — never repeaters."""
from types import SimpleNamespace
from meshterm.core.models import NODE_TYPE_REPEATER
from meshterm.tools.chat import ChatTool
from meshterm.ui.tui import Choice
contacts = [
Contact(name="Ally", public_key="d4" + "0" * 62, node_type=1),
Contact(name="Mystery", public_key="60" + "0" * 62), # type never advertised
Contact(name="Tower", public_key="a1" + "0" * 62, node_type=NODE_TYPE_REPEATER),
]
class _Ui:
def __init__(self) -> None:
self.items = None
async def select(self, title, items, **kw):
self.items = items
return None
ctx = SimpleNamespace(
devstate=_PickerDevstate(contacts), repo=repo, ui=_Ui(), chat=_PickerChat()
)
assert await ChatTool()._pick_conversation(ctx) is None
direct = [
it.value.label
for it in ctx.ui.items
if isinstance(it, Choice)
and isinstance(it.value, Conversation)
and not it.value.is_channel
]
assert "Ally" in direct and "Mystery" in direct and "Tower" not in direct
async def test_picker_del_deletes_history_after_a_red_confirm(repo: Repository) -> None:
"""Del on a contacted thread: red destructive confirm, history gone, unread cleared,
and the re-entered picker's row is hollow (no longer deletable)."""
from types import SimpleNamespace
from meshterm.tools.chat import ChatTool
from meshterm.ui.tui import Choice, DeleteRequest
ally = Contact(name="Ally", public_key="d4" + "0" * 62, node_type=1)
conv = Conversation(label="Ally", is_channel=False, contact=ally)
repo.record_chat_message(ChatMessage(text="hi", peer=conv.peer))
repo.record_chat_message(ChatMessage(text="chan", is_channel=True, channel_id="c0"))
class _Ui:
def __init__(self) -> None:
self.selects = 0
self.dialogs: list[dict] = []
async def select(self, title, items, **kw):
self.selects += 1
assert kw.get("delete_hint") == "Del delete history"
row = next(
it
for it in items
if isinstance(it, Choice)
and isinstance(it.value, Conversation)
and not it.value.is_channel
)
if self.selects == 1:
assert row.deletable # there is history to delete
return DeleteRequest(row.value)
assert not row.deletable # history gone: the row demoted to uncontacted
return None
async def dialog(self, prompt, buttons, **kw):
self.dialogs.append({"prompt": prompt, "buttons": buttons, **kw})
return True # commit the Delete
chat = _PickerChat()
ctx = SimpleNamespace(
devstate=_PickerDevstate([ally]), repo=repo, ui=_Ui(), chat=chat
)
assert await ChatTool()._pick_conversation(ctx) is None
assert ctx.ui.selects == 2 # the picker re-entered after the delete
confirm = ctx.ui.dialogs[0]
assert confirm["destructive"] is True # the reserved red, data loss
assert confirm["buttons"] == [("Cancel", False), ("Delete", True)]
assert repo.recent_chat_messages(is_channel=False, peer=conv.peer) == []
assert [
m.text for m in repo.recent_chat_messages(is_channel=True, channel_id="c0")
] == ["chan"] # channel history survives
assert chat.cleared == [conv.key]
async def test_picker_del_cancel_keeps_the_history(repo: Repository) -> None:
"""Cancelling the confirm leaves the thread's messages untouched."""
from types import SimpleNamespace
from meshterm.tools.chat import ChatTool
from meshterm.ui.tui import Choice, DeleteRequest
ally = Contact(name="Ally", public_key="d4" + "0" * 62, node_type=1)
conv = Conversation(label="Ally", is_channel=False, contact=ally)
repo.record_chat_message(ChatMessage(text="hi", peer=conv.peer))
class _Ui:
def __init__(self) -> None:
self.selects = 0
async def select(self, title, items, **kw):
self.selects += 1
if self.selects == 1:
row = next(
it
for it in items
if isinstance(it, Choice)
and isinstance(it.value, Conversation)
and not it.value.is_channel
)
return DeleteRequest(row.value)
return None
async def dialog(self, prompt, buttons, **kw):
return False # Cancel backs out
chat = _PickerChat()
ctx = SimpleNamespace(
devstate=_PickerDevstate([ally]), repo=repo, ui=_Ui(), chat=chat
)
assert await ChatTool()._pick_conversation(ctx) is None
assert [m.text for m in repo.recent_chat_messages(is_channel=False, peer=conv.peer)] == ["hi"]
assert chat.cleared == []