Trace target: pick from the shared sortable list, with a TRACED lane

The target picker was a hand-rolled two-section list ("Recently traced",
then "Contacts") — the one contact enumerator in the app that didn't ride
the shared ContactListScreen. Move it onto that widget so it sorts,
filters, and steers exactly like the Contacts screen and the courier
recipient: full NAME · HEARD · PKTS · KEY lanes, Ctrl+arrow sort ring,
type-to-filter. Every node type is listed — a trace answers "can I reach
this node?" for a repeater or room, not just a companion.

The recency the old section carried becomes a first-class sortable lane:
ContactListScreen grows an optional TRACED column (opt-in via show_traced)
between name and heard, showing how long ago each node was last traced,
recency-heat coloured. It is the picker's default sort, descending, so the
most-recently-traced node leads and never-traced ones gather at the
bottom. A new repository query (target_last_traced) feeds it, folded onto
contacts the same way the old recent-targets list was.

The free-text prompt stays as the fallback when there are no contacts to
list — still the only way to trace a bare key prefix off-list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-19 13:59:51 -04:00
1 parent 40c46d3c75
commit ebb1d40361
6 files changed
+290 -124

No files matched your search

+26
View File
@@ -480,6 +480,32 @@ class Repository:
).fetchall()
return [row["target"] for row in rows]
def target_last_traced(self) -> dict[str, datetime]:
"""Per trace target, when it was most recently traced (any outcome).
Feeds the Trace-target picker's ``TRACED`` column and its default sort — how long
ago each node was last aimed at, so the picker opens with the most-recently-traced
node on top. Keyed by the raw target string the trace was filed under (a contact
name or a hex hash), exactly like :meth:`target_trace_counts`; the caller matches
its node against those keys. Timed-out attempts count — you *traced* the node
whether or not it answered — and the hand-composed path walks filed under
:data:`~meshterm.core.models.PATH_TRACE_TARGET` are excluded (they name no target).
Returns:
``target → last-traced timestamp`` (aware UTC) for every distinct non-path-walk
target.
"""
rows = self._conn.execute(
"SELECT target, MAX(created_at) AS latest FROM traces "
"WHERE target != ? GROUP BY target",
(PATH_TRACE_TARGET,),
).fetchall()
out: dict[str, datetime] = {}
for row in rows:
if row["latest"]:
out[row["target"]] = datetime.fromisoformat(row["latest"])
return out
def target_trace_counts(self) -> dict[str, tuple[int, int]]:
"""Per trace target, its ``(successes, total)`` across every stored trace.
+82 -89
View File
@@ -24,6 +24,7 @@ stored history reads the same no matter where it came from.
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
import typer
@@ -35,8 +36,6 @@ from ..services import trace_runner
from ..ui.widgets import stats_panel
from .base import Tool, ToolResult, register
_BACK = "__back__"
#: The characters a stored trace target must consist of to be treated as a hex key
#: prefix when folding it back to a contact name in the target picker.
_HEX_DIGITS = frozenset("0123456789abcdef")
@@ -118,63 +117,74 @@ class TraceTool(Tool):
return ToolResult(summary={"sessions": 1, "traces": traces})
async def _pick_target(self, ctx: AppContext) -> Optional[str]:
"""Pick a trace target: recently traced first, then contacts by recency.
"""Pick a trace target from the shared sortable contact list.
The same ``NAME · TRACED · HEARD · PKTS · KEY`` lanes, Ctrl+arrow sort ring, and
type-to-filter the Contacts screen and the courier recipient draw — but with every
node type listed (a trace answers *can I reach this node?* for a repeater or room
just as for a companion) and an extra ``TRACED`` lane: how long ago each node was
last traced. The list opens sorted by ``TRACED`` descending, so the most recently
traced node leads and never-traced ones gather at the bottom. Enter commits the
highlighted node's name as the target.
With no known contacts at all the list would be empty, so a free-text prompt takes
over instead — also the only way to trace a bare key prefix for a node the device
doesn't carry as a contact.
Args:
ctx: Shared application context.
Returns:
The chosen target name, or ``None`` if cancelled. With no known contacts and
no history, falls back to a free-text prompt so a key prefix can be typed.
The chosen target name, or ``None`` if cancelled.
"""
from ..ui.tui import Choice, Separator
from ..ui.widgets import _DEFAULT_GLYPH, _NODE_GLYPHS
from ..ui.contactlist import (
TRACE_SORT_COLUMNS,
TRACE_SORT_OPENS_ASCENDING,
ContactListScreen,
ContactRow,
)
from ..ui.surface import TuiUi
from ..ui.timemachine_screen import _routing_prefix_bytes
from ..ui.widgets import ContactsSort, _contact_pkts
# Through the session cache: this picker runs on every Trace open, and the contacts
# table is a slow round-trip on a busy node — re-reading it here (in front of the
# already-cached trace screen) is what kept opening Trace feeling like a stall. See
# the note in the ``nodes`` tool and :class:`~meshterm.services.device_state.DeviceState`.
# :class:`~meshterm.services.device_state.DeviceState`.
contacts = await ctx.devstate.contacts()
recent = _recent_targets(ctx.repo.traced_targets(), contacts)
if not contacts and not recent:
if not contacts or not isinstance(ctx.ui, TuiUi):
# No contacts to list (or no full-screen session): a free-text prompt is the
# only way in — and the only way to trace a bare key prefix off-list.
entered = await ctx.ui.text("Target node (name or key prefix):")
return entered.strip() if entered else None
by_name = {c.name: c for c in contacts}
def row(name: str) -> Choice:
contact = by_name.get(name)
glyph, style = (
_NODE_GLYPHS.get(contact.node_type, _DEFAULT_GLYPH)
if contact is not None
else _DEFAULT_GLYPH
traced = _last_traced_by_name(contacts, ctx.repo.target_last_traced())
counts = {n.node: n.count for n in ctx.repo.heard_nodes() if n.node}
prefix_bytes = await _routing_prefix_bytes(ctx)
rows = [
ContactRow(
value=c,
name=c.name,
key=c.public_key or c.key_prefix or "",
node_type=c.node_type,
last_seen=c.last_seen,
count=_contact_pkts(c, counts),
last_traced=traced.get(c.name),
)
label = Text(glyph, style=style)
label.append(f" {name}")
return Choice(title=label, value=name)
items: list = []
listed: set[str] = set()
if recent:
items.append(Separator("── ⏱ Recently traced ──", style="accent"))
for name in recent:
items.append(row(name))
listed.add(name)
remaining = [c for c in contacts if c.name not in listed]
if remaining:
items.append(Separator("── 👤 Contacts ──", style="accent"))
for contact in _by_recency(remaining):
items.append(row(contact.name))
items.append(Separator(" "))
items.append(Choice(title="Back", value=_BACK))
choice = await ctx.ui.select(
"Trace target — pick a target", items, wrap=False
for c in contacts
]
picker = ContactListScreen(
"Trace target — pick a target",
rows=rows,
prefix_bytes=prefix_bytes,
sort=ContactsSort.from_name(
"traced", TRACE_SORT_COLUMNS, TRACE_SORT_OPENS_ASCENDING
),
footer_hint="↑↓ move · ^←→↑↓ sort · type filter · Enter select · Esc back",
show_traced=True,
)
if choice in (None, _BACK):
return None
return str(choice)
chosen = await ctx.ui.session.run_screen(picker)
return chosen.name if isinstance(chosen, Contact) else None
# -- scripted (CLI) -------------------------------------------------------------
@@ -387,61 +397,44 @@ async def _trace_once_cli(
return ToolResult(summary=summary, message=message)
def _recent_targets(stored: list[str], contacts: list[Contact]) -> list[str]:
"""Clean the stored recent-target names for the picker's *Recently traced* section.
def _last_traced_by_name(
contacts: list[Contact], traced: dict[str, datetime]
) -> dict[str, datetime]:
"""Map each contact's name to when that node was last traced.
Targets are recorded exactly as the user addressed them, so the same node can
appear once as a contact name and again as a raw hex prefix typed some other day.
Each stored target is folded back to its contact's current name when it matches one
(by name, case-insensitively, or as a prefix of a contact's public key), then
deduplicated with order preserved — so every node shows once, under the name the
rest of the picker uses.
The Trace picker's ``TRACED`` lane and default sort read this. Stored trace targets are
filed exactly as the user addressed them — a contact name one day, a raw hex key prefix
another — so each target is folded onto the contact it names (by name, case-insensitively,
or as a prefix of a contact's public key) and the *latest* trace time wins, so a node
traced under both spellings still shows one honest "last traced" age. This is the inverse
of the picker's old recent-targets fold, kept per-contact rather than as a name list.
Args:
stored: Recent trace destinations, most recent first, as recorded.
contacts: The device's current contacts to fold names against.
contacts: The device's current contacts.
traced: ``target → last-traced time`` from
:meth:`~meshterm.persistence.repository.Repository.target_last_traced`.
Returns:
The display names, most recently traced first, one per node.
``contact name → last-traced time`` for every contact the history can place.
"""
by_fold = {c.name.casefold(): c.name for c in contacts}
out: dict[str, datetime] = {}
def fold(target: str) -> str:
def note(name: str, when: datetime) -> None:
current = out.get(name)
if current is None or when > current:
out[name] = when
for target, when in traced.items():
named = by_fold.get(target.casefold())
if named is not None:
return named
note(named, when)
continue
needle = target.lower().removeprefix("0x")
# Only fold plausible key prefixes (≥2 bytes of hex) — a short hex-looking
# *name* like "ace" must not be mistaken for an address.
# Only fold plausible key prefixes (≥2 bytes of hex) — a short hex-looking *name*
# like "ace" must not be mistaken for an address.
if len(needle) >= 4 and all(ch in _HEX_DIGITS for ch in needle):
for c in contacts:
if (c.public_key or "").lower().startswith(needle):
return c.name
return target
names: list[str] = []
seen: set[str] = set()
for target in stored:
name = fold(target)
if name.casefold() not in seen:
seen.add(name.casefold())
names.append(name)
return names
def _by_recency(contacts: list[Contact]) -> list[Contact]:
"""Order contacts most-recently-heard first (never-heard last, alphabetically).
Args:
contacts: The contacts to order.
Returns:
A new sorted list; the input is left untouched.
"""
def key(c: Contact) -> tuple:
heard = c.last_seen is not None and getattr(c.last_seen, "tzinfo", None) is not None
stamp = c.last_seen.timestamp() if heard else 0.0
return (not heard, -stamp, c.name.casefold())
return sorted(contacts, key=key)
for contact in contacts:
if (contact.public_key or "").lower().startswith(needle):
note(contact.name, when)
return out
+91 -27
View File
@@ -51,6 +51,11 @@ _HASH_MIN = 8
#: and the key lane takes the rest (see :meth:`ContactListScreen._lane_widths`).
_LEAD = 2 + 2 + (2 + 5) + (2 + 5) + 3
#: The optional TRACED lane's width in cells: a 2-cell gap plus a 6-wide age field (wide
#: enough for the ``TRACED`` header). Only the Trace-target picker shows it (``show_traced``),
#: inserted between the name lane and HEARD; every other contact list omits it (and its width).
_TRACED_LANE = 2 + 6
#: The muted ``(you)`` tag on the own-node lane (see :func:`_lane`), its width folded into
#: the name lane's content sizing so the tag never truncates.
_YOU_TAG = " (you)"
@@ -66,6 +71,17 @@ SORT_OPENS_ASCENDING: dict[str, bool] = {
"name": True, "heard": True, "packets": False, "hash": True,
}
#: The Trace-target picker's wider sort ring. It adds a ``traced`` column — how long ago the
#: node was last traced — between name and heard, and opens on it *descending*: the
#: most-recently-traced node on top, never-traced gathered at the bottom (a trace time is the
#: metric, so descending is newest-first — see :func:`_ordered`). The picker builds its
#: :class:`~meshterm.ui.widgets.ContactsSort` over these, so the Ctrl+arrows walk all five
#: columns; a list without the ``traced`` lane keeps to :data:`SORT_COLUMNS`.
TRACE_SORT_COLUMNS: tuple[str, ...] = ("name", "traced", "heard", "packets", "hash")
TRACE_SORT_OPENS_ASCENDING: dict[str, bool] = {
"name": True, "traced": False, "heard": True, "packets": False, "hash": True,
}
#: The cyan the active sort column and its triangle are lit in, matching the static Contacts
#: table's header (see :func:`~meshterm.ui.widgets._sort_header`) so sort cues read
#: identically everywhere.
@@ -93,6 +109,10 @@ class ContactRow:
last_seen: When the contact was last heard (aware UTC) — fills the heard lane,
coloured by recency heat; ``None`` reads ``never`` in the cold style.
count: The packet tally; ``None`` renders a faint ``—`` (never overheard).
last_traced: When this node was last traced (aware UTC) — fills the optional
``TRACED`` lane (only shown when the list is built with ``show_traced``),
coloured by recency heat; ``None`` reads ``never``. Ignored by lists that don't
show the lane.
you: Whether this is our own node: the ``★`` marker, the pure-white ``you`` name
style with a muted ``(you)`` tag, faint ``—`` heard/packet lanes (we never
overhear ourselves), pinned above the sorted block whatever the sort.
@@ -104,15 +124,17 @@ class ContactRow:
node_type: Optional[int] = None
last_seen: Optional[datetime] = None
count: Optional[int] = None
last_traced: Optional[datetime] = None
you: bool = False
def _header(name_w: int, sort: ContactsSort) -> Text:
def _header(name_w: int, sort: ContactsSort, show_traced: bool = False) -> Text:
"""Column labels over the contact lanes (see :func:`_lane`).
The lanes read ``NAME · HEARD · PKTS · KEY``, matching the row builder. The four
leading spaces cover the select screen's pointer column (2 cells) plus the one-cell
type glyph and its gap, so each label lands over its lane.
The lanes read ``NAME · HEARD · PKTS · KEY`` — or ``NAME · TRACED · HEARD · PKTS · KEY``
when ``show_traced`` inserts the Trace picker's extra lane — matching the row builder.
The four leading spaces cover the select screen's pointer column (2 cells) plus the
one-cell type glyph and its gap, so each label lands over its lane.
All four columns are sortable, so any one can be the active sort. The active column's
label *and* its direction triangle (``▲`` ascending, ``▼`` descending) are lit cyan
@@ -136,7 +158,11 @@ def _header(name_w: int, sort: ContactsSort) -> Text:
header = Text(" ", style="muted") # pointer (2) + the row's type glyph and gap (2)
column(header, "NAME", "name", pad_to=name_w)
header.append(" ", style="muted") # gap to the HEARD lane
header.append(" ", style="muted") # gap to the first metric lane
if show_traced:
# The Trace picker's extra lane, sized 6 wide (its own label's width) so the
# ``TRACED`` header fits; the column helper's 2-cell reserve is the gap to HEARD.
column(header, "TRACED", "traced")
column(header, "HEARD", "heard")
column(header, f"{'PKTS':>5}", "packets")
# KEY sits one cell further out than the other lanes so a packets-sort triangle —
@@ -146,16 +172,18 @@ def _header(name_w: int, sort: ContactsSort) -> Text:
return header
def _you_lane(row: ContactRow, name_w: int, prefix_bytes: int, hash_w: int) -> Text:
def _you_lane(
row: ContactRow, name_w: int, prefix_bytes: int, hash_w: int, show_traced: bool = False
) -> Text:
"""Our own node's lane — laid out exactly like :func:`_lane`'s regular rows.
Drawn like the map and the static table draw us: the ``★`` self marker (yellow), the
name in the pure-white ``you`` style with a muted ``(you)`` tag, and our key with its
hash lit at the routing width, filling the flexing key lane — our 64-hex key is longer
than any lane, so it shows as many digits as fit and ellipsizes. The heard and packet
lanes read a faint ``—``: we never overhear ourselves, so there is no reception age or
count to show. With no reachable device to name us, the name falls back to a bare
``you`` and the key to ``?``.
lanes (and the optional ``TRACED`` lane) read a faint ``—``: we never overhear or trace
ourselves, so there is no reception age, count, or trace age to show. With no reachable
device to name us, the name falls back to a bare ``you`` and the key to ``?``.
"""
text = Text(no_wrap=True, overflow="ellipsis")
text.append(_SELF[0], style=_SELF[1])
@@ -172,6 +200,9 @@ def _you_lane(row: ContactRow, name_w: int, prefix_bytes: int, hash_w: int) -> T
else:
text.append(fit_cells(row.name or "you", name_w), style="you")
text.append(" ")
if show_traced:
text.append(f"{'—':>6}", style="faint") # traced: we never trace ourselves
text.append(" ")
text.append(f"{'—':>5}", style="faint") # heard: we don't hear ourselves
text.append(" ")
text.append(f"{'—':>5}", style="faint") # packets: nothing to count
@@ -183,18 +214,21 @@ def _you_lane(row: ContactRow, name_w: int, prefix_bytes: int, hash_w: int) -> T
return text
def _lane(row: ContactRow, name_w: int, prefix_bytes: int, hash_w: int) -> Text:
def _lane(
row: ContactRow, name_w: int, prefix_bytes: int, hash_w: int, show_traced: bool = False
) -> Text:
"""One contact as fixed, colour-coded lanes under :func:`_header`'s columns.
The type glyph leads (the app's shared marker palette), so the mark reads ``▲`` for a
repeater, ``■`` for a room, ``◉`` for a sensor, ``●`` for a plain node. The name takes
the contact's hash-derived palette hue (a nameless contact's ``unknown`` placeholder
stays muted — colour marks a name, and the hash lane already carries the identity) and
the flexing name lane (``name_w`` cells). The last-heard age follows, glowing with
recency heat (brighter = fresher) so a freshly heard contact still reads hot at a
glance; then the packet count, right-aligned — a contact never overheard reads a faint
``—``; the key closes the row in the shared key widget, its hash lit at the device's
routing width, or a muted ``?`` when no key is known at all. An own-node row
the flexing name lane (``name_w`` cells). With ``show_traced`` the last-traced age comes
next (the Trace picker's lane, recency-heat coloured, ``never`` cold). The last-heard age
follows, glowing with recency heat (brighter = fresher) so a freshly heard contact still
reads hot at a glance; then the packet count, right-aligned — a contact never overheard
reads a faint ``—``; the key closes the row in the shared key widget, its hash lit at the
device's routing width, or a muted ``?`` when no key is known at all. An own-node row
(:attr:`ContactRow.you`) takes its own drawing — see :func:`_you_lane`.
Args:
@@ -203,9 +237,11 @@ def _lane(row: ContactRow, name_w: int, prefix_bytes: int, hash_w: int) -> Text:
prefix_bytes: The hash width in bytes to light at the head of the key.
hash_w: The flexing key lane's width in cells (a short key pads out to it; see
:func:`~meshterm.ui.widgets.highlighted_hash`).
show_traced: Whether to draw the ``TRACED`` lane (last-traced age) between the name
and heard lanes — only the Trace-target picker does.
"""
if row.you:
return _you_lane(row, name_w, prefix_bytes, hash_w)
return _you_lane(row, name_w, prefix_bytes, hash_w, show_traced)
glyph, glyph_style = _NODE_GLYPHS.get(row.node_type, _DEFAULT_GLYPH)
secs = _age_seconds(row.last_seen)
text = Text(no_wrap=True, overflow="ellipsis")
@@ -216,6 +252,10 @@ def _lane(row: ContactRow, name_w: int, prefix_bytes: int, hash_w: int) -> Text:
style=name_style(row.name, row.key) if row.name else "muted",
)
text.append(" ")
if show_traced:
tsecs = _age_seconds(row.last_traced)
text.append(f"{_format_age(tsecs):>6}", style=_recency_style(tsecs))
text.append(" ")
text.append(f"{_format_age(secs):>5}", style=_recency_style(secs))
text.append(" ")
if row.count is None:
@@ -233,11 +273,14 @@ def _lane(row: ContactRow, name_w: int, prefix_bytes: int, hash_w: int) -> Text:
def _ordered(rows: list[ContactRow], sort: ContactsSort) -> list[ContactRow]:
"""Order the sortable rows by the active sort (own-node rows are pinned elsewhere).
The four columns' natural metrics: name A→Z, ``heard`` by age so ascending is
The columns' natural metrics: name A→Z, ``heard`` by age so ascending is
most-recently-heard first (a never-heard row gathers at the old end), ``packets`` by
count, ``hash`` by the displayed key (ascending = ``0`` → ``f``). A name-ascending
pre-sort is the stable tiebreak, so two contacts sharing a metric keep an A→Z order
under both directions rather than flipping with the primary key.
count, ``hash`` by the displayed key (ascending = ``0`` → ``f``). The optional
``traced`` column sorts by *trace time*, so descending — its natural open (see
:data:`TRACE_SORT_OPENS_ASCENDING`) — is most-recently-traced first, with never-traced
rows carrying ``-inf`` so they gather at the bottom. A name-ascending pre-sort is the
stable tiebreak, so two contacts sharing a metric keep an A→Z order under both
directions rather than flipping with the primary key.
"""
def key_name(row: ContactRow) -> str:
@@ -250,6 +293,13 @@ def _ordered(rows: list[ContactRow], sort: ContactsSort) -> list[ContactRow]:
return row.count or 0
if sort.column == "hash":
return row.key
if sort.column == "traced":
# Sort by trace time (epoch), so descending = newest trace first and a
# never-traced row (-inf) always lands last, whichever direction is active.
when = row.last_traced
if when is None or getattr(when, "tzinfo", None) is None:
return float("-inf")
return when.timestamp()
secs = _age_seconds(row.last_seen) # "heard": ascending = freshest first
return secs if secs is not None else float("inf")
@@ -263,7 +313,10 @@ class ContactListScreen(SelectScreen):
The rows run in aligned lanes — name (in the contact's hash-derived palette hue),
last-heard age (coloured by recency heat), packet count, and key — own-node rows
first, then the rest in the active sort order. The lanes anchor to the left: the name lane is sized to its widest name (not
first, then the rest in the active sort order. Building with ``show_traced`` inserts
one more lane, ``TRACED`` (how long ago the node was last traced), between name and
heard — the Trace-target picker only, which opens sorted by it. The lanes anchor to
the left: the name lane is sized to its widest name (not
the terminal), so the columns stay put as the window widens and the freed width flows
to the key lane, which shows each key as fully as it fits (see :meth:`_lane_widths`).
It is a full-screen list, not a floating popup.
@@ -290,6 +343,7 @@ class ContactListScreen(SelectScreen):
prompt: str = "",
lead: Optional[list] = None,
footer_hint: str = _HINT,
show_traced: bool = False,
) -> None:
"""Build the list over already-resolved rows.
@@ -300,15 +354,20 @@ class ContactListScreen(SelectScreen):
prefix_bytes: The hash width in bytes to light at the head of each key.
sort: The sort state, mutated in place by the Ctrl+arrows — pass the same
instance across re-opens so the chosen order persists. Its ring should
span :data:`SORT_COLUMNS` for the hash column to be reachable.
span :data:`SORT_COLUMNS` for the hash column to be reachable (or
:data:`TRACE_SORT_COLUMNS` when ``show_traced`` adds the traced column).
prompt: An optional instruction shown above the list.
lead: Rows (choices/separators) drawn above the column header — the Time
Machine's whole-mesh row and its section heading; ``None`` for none.
footer_hint: Footer key hint; the default advertises the full grammar.
show_traced: Whether to draw the ``TRACED`` lane (last-traced age) between the
name and heard lanes — the Trace-target picker only. Pair it with a ``sort``
whose ring includes the ``traced`` column.
"""
self._contact_rows = rows
self._prefix_bytes = prefix_bytes
self._sort = sort
self._show_traced = show_traced
self._lead = list(lead) if lead else []
# Provisional until the first render learns the true width (see render_body).
self._name_w = _NAME_MIN
@@ -327,13 +386,16 @@ class ContactListScreen(SelectScreen):
Own-node rows lead the lanes and stay first whatever the sort: only the block
below them reorders (see :func:`_ordered`)."""
items: list = list(self._lead)
items.append(Separator(_header(self._name_w, self._sort)))
items.append(Separator(_header(self._name_w, self._sort, self._show_traced)))
pinned = [row for row in self._contact_rows if row.you]
rest = [row for row in self._contact_rows if not row.you]
for row in (*pinned, *_ordered(rest, self._sort)):
items.append(
Choice(
_lane(row, self._name_w, self._prefix_bytes, self._hash_w),
_lane(
row, self._name_w, self._prefix_bytes, self._hash_w,
self._show_traced,
),
row.value,
)
)
@@ -380,11 +442,13 @@ class ContactListScreen(SelectScreen):
window grows; it only yields when a very long name would starve the key lane past
its :data:`_HASH_MIN` floor. The key lane then takes all the width the fixed
lanes and the name lane leave, so keys show as fully as they fit — a heard id's 12
hex digits with room to spare, a 64-hex key ellipsized to the lane.
hex digits with room to spare, a 64-hex key ellipsized to the lane. The optional
``TRACED`` lane widens the fixed lead by :data:`_TRACED_LANE` when shown.
"""
name_cap = max(_NAME_MIN, width - _LEAD - _HASH_MIN)
lead = _LEAD + (_TRACED_LANE if self._show_traced else 0)
name_cap = max(_NAME_MIN, width - lead - _HASH_MIN)
name_w = max(_NAME_MIN, min(self._widest_name(), name_cap))
hash_w = max(_HASH_MIN, width - _LEAD - name_w)
hash_w = max(_HASH_MIN, width - lead - name_w)
return name_w, hash_w
def render_body(self, width: int) -> list[str]:
+16
View File
@@ -194,6 +194,22 @@ def test_target_trace_counts_tallies_successes_and_totals(repo: Repository) -> N
assert PATH_TRACE_TARGET not in counts # path walks name no target
def test_target_last_traced_keeps_latest_per_target(repo: Repository) -> None:
"""Per target, the most recent trace time — failures counted, path walks excluded."""
run = repo.start_run("trace", {})
early = datetime(2026, 1, 10, 9, 0, tzinfo=timezone.utc)
late = datetime(2026, 1, 12, 18, 0, tzinfo=timezone.utc)
repo.record_trace(run, _make_trace("Alice", ("r", 1.0), ts=early))
repo.record_trace(run, _make_trace("Alice", success=False, ts=late)) # newer, timed out
repo.record_trace(run, _make_trace("Bob", ("r", 1.0), ts=early))
repo.record_trace(run, _make_trace(PATH_TRACE_TARGET, ("r", 3.0), ts=late))
last = repo.target_last_traced()
assert last["Alice"] == late # the failed-but-newer trace wins — you still traced it
assert last["Bob"] == early
assert PATH_TRACE_TARGET not in last # path walks name no target
# -- latest_trace edge cases -----------------------------------------------
+19 -8
View File
@@ -62,21 +62,32 @@ def test_parse_trace_path_rejects_mixed_widths() -> None:
trace_runner.parse_trace_path("3d,a1b2c3", contacts=[])
def test_recent_targets_folds_hex_to_names_and_dedupes() -> None:
"""Stored targets collapse to contact names so a node shows once in the picker.
def test_last_traced_by_name_folds_hex_to_contacts_and_keeps_latest() -> None:
"""The Trace picker's TRACED lane folds every stored target onto its contact.
The same node traced as "alice" one day and by its key prefix another must appear
a single time under its contact name; a hex-looking *name* stays a name, and
unknown prefixes pass through untouched.
A node traced as "alice" one day and by its key prefix another shows one honest last-
traced time — the latest of the two. A hex-looking *name* stays a name (matched by
name, not address), and an unknown prefix that names no contact simply drops out.
"""
from meshterm.tools.trace import _recent_targets
from datetime import datetime, timezone
from meshterm.tools.trace import _last_traced_by_name
contacts = [
Contact(name="Alice", public_key="d4e5f6a7" + "00" * 28, key_prefix="d4e5f6a7"),
Contact(name="cafe", public_key="12ab34cd" + "00" * 28, key_prefix="12ab34cd"),
]
stored = ["d4e5f6", "alice", "Alice", "cafe", "beefbeef", "d4e5f6a7"]
assert _recent_targets(stored, contacts) == ["Alice", "cafe", "beefbeef"]
def when(day: int) -> datetime:
return datetime(2026, 7, day, tzinfo=timezone.utc)
traced = {
"d4e5f6": when(1), # Alice by an early key-prefix trace
"Alice": when(9), # …and by name, more recently — the latest must win
"cafe": when(5), # a hex-looking *name*, matched as a name
"beefbeef": when(3), # a prefix that names no contact — dropped
}
assert _last_traced_by_name(contacts, traced) == {"Alice": when(9), "cafe": when(5)}
def test_node_type_resolver_matches_hop_hash_to_contact_type() -> None:
+56
View File
@@ -666,6 +666,62 @@ def test_picker_header_highlights_only_the_active_sort_column() -> None:
assert not any(any(other in seg for other in ("NAME", "HEARD", "KEY")) for seg in lit)
def test_traced_lane_shows_only_when_enabled() -> None:
"""The optional TRACED lane sits between NAME and HEARD — and only with show_traced."""
from meshterm.ui.contactlist import (
TRACE_SORT_COLUMNS,
TRACE_SORT_OPENS_ASCENDING,
ContactRow,
_header,
_lane,
)
from meshterm.ui.widgets import ContactsSort
sort = ContactsSort.from_name("traced", TRACE_SORT_COLUMNS, TRACE_SORT_OPENS_ASCENDING)
# Off (the default of every other list): no TRACED column, lanes read NAME·HEARD·PKTS·KEY.
assert "TRACED" not in _header(10, ContactsSort.from_name("heard")).plain
# On: TRACED lands between NAME and HEARD, and opens as the default sort (descending).
header = _header(10, sort, show_traced=True).plain
assert header.index("NAME") < header.index("TRACED") < header.index("HEARD")
assert "TRACED ▼" in header
row = ContactRow(
value="x", name="Poly", key="3d" * 6,
last_seen=utcnow() - timedelta(minutes=90),
last_traced=utcnow() - timedelta(minutes=2),
)
# The traced age ("2m") renders left of the heard age ("1h") in the shown lane.
traced_lane = _lane(row, 10, 1, 12, True).plain
assert traced_lane.index("2m") < traced_lane.index("1h")
# With the lane off, only the heard age shows.
assert "2m" not in _lane(row, 10, 1, 12, False).plain
def test_traced_sort_orders_most_recent_first_never_last() -> None:
"""The traced sort opens most-recently-traced first, never-traced gathered at the bottom."""
from meshterm.ui.contactlist import (
TRACE_SORT_COLUMNS,
TRACE_SORT_OPENS_ASCENDING,
ContactRow,
_ordered,
)
from meshterm.ui.widgets import ContactsSort
now = utcnow()
rows = [
ContactRow(value="stale", name="Stale", last_traced=now - timedelta(hours=5)),
ContactRow(value="never", name="Never", last_traced=None),
ContactRow(value="fresh", name="Fresh", last_traced=now - timedelta(minutes=1)),
]
sort = ContactsSort.from_name("traced", TRACE_SORT_COLUMNS, TRACE_SORT_OPENS_ASCENDING)
assert sort.column == "traced" and sort.ascending is False # opens descending
# Descending (the default): freshest trace on top, never-traced last.
assert [r.value for r in _ordered(rows, sort)] == ["fresh", "stale", "never"]
# Ascending flips the traced order but keeps never-traced out of the fresh block.
sort.ascending = True
assert [r.value for r in _ordered(rows, sort)] == ["never", "stale", "fresh"]
def _picker(listed, *, prefix_bytes=0, sort=None, type_of=None, resolve_key=None, width=80):
"""Build a rendered picker over ``listed`` and return it (rows sized to ``width``)."""
from meshterm.ui.contactlist import SORT_COLUMNS, SORT_OPENS_ASCENDING