Hash and name conventions: display budget, shared hue palette

highlighted_hash grows a width budget - shorter than the key shows an
ellipsis, shorter keys pad so lanes align - making it THE hash widget.
The chat sender palette moves to theme.name_style as the app-wide node
name colour rule; our own node is always the pure-white "you" style
(the nodes table's self row now follows it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-07-11 18:12:01 -04:00
1 parent f9411bfc9b
commit b50d201c24
5 files changed
+72 -38

No files matched your search

+7 -19
View File
@@ -24,7 +24,7 @@ from rich.text import Text
from ..core.events import EventKind, MeshEvent
from ..core.models import ChatMessage, Contact, Conversation, Message, utcnow
from .theme import snr_style
from .theme import name_style, snr_style
from .tui.prompt import _LineEditor
from .tui.render import render_hanging, render_lines
from .tui.screen import CANCEL, Screen
@@ -36,20 +36,6 @@ if TYPE_CHECKING:
#: How many past messages to load into the transcript when a conversation opens.
_HISTORY_LIMIT = 200
#: Palette of distinct, dark-theme-friendly colors cycled through to give each channel
#: sender its own hue (red is reserved for errors, so it's excluded). ``you`` and unknown
#: senders are styled separately.
_SENDER_COLORS = (
"bold #f472b6", # pink
"bold #60a5fa", # blue
"bold #34d399", # green
"bold #a78bfa", # violet
"bold #fb923c", # orange
"bold #22d3ee", # cyan
"bold #a3e635", # lime
"bold #e879f9", # fuchsia
)
#: Matches the ``Name: message`` convention channel senders use to identify themselves
#: (the protocol carries no sender field). The name is 1–20 non-colon characters and must
#: be followed by ``": "`` — conservative enough to leave ``http://…`` and ``note:x`` alone.
@@ -89,11 +75,13 @@ _BYTES_ORANGE = "bold #ff9500"
def _sender_hue(sender: str) -> str:
"""The stable per-sender colour a name is drawn in, keyed on the name's characters.
Shared by the live transcript (sender headers, ``@mentions``) and the conversation list
(a contact's colour dot, a channel preview's inline sender), so a person reads the same
colour everywhere. ``you`` and unknown (``·``) senders are handled by the caller.
The app-wide name palette (:func:`~meshterm.ui.theme.name_style`), shared by the live
transcript (sender headers, ``@mentions``), the conversation list (a contact's colour
dot, a channel preview's inline sender), the dashboard feed, and the packet viewer, so
a person reads the same colour everywhere. ``you`` and unknown (``·``) senders are
handled by the caller.
"""
return _SENDER_COLORS[sum(map(ord, sender)) % len(_SENDER_COLORS)]
return name_style(sender)
def _split_channel_sender(text: str) -> tuple[Optional[str], str]:
+34 -2
View File
@@ -17,8 +17,8 @@ MESH_THEME = Theme(
# Reversed error (the text-editor cursor sitting on an over-budget character). Baked
# in for the same reason as ``selected`` — "reverse err" would render as plain text.
"err.reverse": "reverse bold #f87171",
# Our own messages in a chat: pure white, deliberately outside the per-sender hue
# palette (see meshterm.ui.chat._SENDER_COLORS) so "you" is always easy to spot.
# Our own node, anywhere it is named: pure white, deliberately outside the
# per-name hue palette (see NAME_COLORS below) so "you" is always easy to spot.
"you": "bold #ffffff",
# A confirmed companion's name on the startup device picker: pure white so the
# devices we've actually talked to before jump out above the merely-detected ports.
@@ -121,6 +121,38 @@ def hint_style(border_style: str) -> str:
return name if name in MESH_THEME.styles else "muted"
#: Palette of distinct, dark-theme-friendly colors cycled through to give each node or
#: sender name its own stable hue (red is reserved for errors, so it's excluded). The
#: app-wide rule: a node's *name* is always coloured — by this palette, unless the
#: context already colours it (a picker's recency heat, a chart's quality tint) — and
#: our own node is always the pure-white ``you`` style instead, so "us" never blends
#: into the crowd. Shared by the chat transcript, the dashboard feed, and the packet
#: viewer, so one node reads as one colour everywhere.
NAME_COLORS = (
"bold #f472b6", # pink
"bold #60a5fa", # blue
"bold #34d399", # green
"bold #a78bfa", # violet
"bold #fb923c", # orange
"bold #22d3ee", # cyan
"bold #a3e635", # lime
"bold #e879f9", # fuchsia
)
def name_style(name: str) -> str:
"""The stable per-name colour a node or sender is drawn in, keyed on its characters.
Args:
name: The display name (not a hash — hashes stay muted).
Returns:
A style string from :data:`NAME_COLORS`; the same name always maps to the
same hue, so a node keeps its colour across screens and sessions.
"""
return NAME_COLORS[sum(map(ord, name)) % len(NAME_COLORS)]
def snr_style(snr: float | None) -> str:
"""Return a theme style name describing an SNR value's quality.
+1 -1
View File
@@ -1244,7 +1244,7 @@ async def _open_session(ctx: "AppContext", target: Optional[str]) -> int:
title = Text(f"{w} byte{'s' if w > 1 else ' '}")
if sample:
title.append(" ")
title.append_text(highlighted_hash(sample[:16], w))
title.append_text(highlighted_hash(sample, w, width=16))
if w == device_width:
title.append(" · device default", style="muted")
items.append(Choice(title=title, value=w))
+28 -14
View File
@@ -233,29 +233,41 @@ def traces_table(
return table
def highlighted_hash(value: str, prefix_bytes: int) -> Text:
"""Render a full hex key with its leading path-hash prefix highlighted.
def highlighted_hash(value: str, prefix_bytes: int, width: Optional[int] = None) -> Text:
"""Render a hex key with its leading path-hash prefix highlighted — THE hash widget.
The first ``prefix_bytes`` bytes are the slice other nodes address in a forced trace
path (the path-hash); they are shown in the brand colour and the remainder muted, so
the addressable prefix stands out within the otherwise full key.
The one way MeshTerm displays a hash, wherever one appears: the first
``prefix_bytes`` bytes are the slice other nodes address in a forced trace path
(the path-hash), shown in the brand colour with the remainder muted, so the
addressable prefix stands out within the otherwise full key. A ``width`` budget
shorter than the key ellipsizes it (the ``…`` takes the colour of the digit it
replaces, so a highlight wider than the budget still reads as one).
Args:
value: The full key as hex, optionally ``0x``-prefixed and mixed-case.
value: The key as hex, optionally ``0x``-prefixed and mixed-case.
prefix_bytes: Number of leading bytes the current path-hash mode addresses; ``0``
(or negative) leaves the whole key un-highlighted.
width: Display budget in cells; a longer key shows ``width - 1`` digits plus an
ellipsis, a shorter one is right-padded to the budget so lanes stay aligned.
``None`` shows the key whole, unpadded.
Returns:
A styled :class:`Text` of the full key.
A styled :class:`Text` of the key (exactly ``width`` cells when given).
"""
raw = value.lower().removeprefix("0x")
split = max(0, prefix_bytes) * 2
pad = 0
ellipsis = False
if width is not None and len(raw) > width:
raw, ellipsis = raw[: max(0, width - 1)], True
elif width is not None:
pad = width - len(raw)
text = Text()
if split:
text.append(raw[:split], style="brand")
text.append(raw[split:], style="muted")
else:
text.append(raw, style="muted")
text.append(raw[:split], style="brand")
text.append(raw[split:], style="muted")
if ellipsis:
text.append("…", style="brand" if len(raw) < split else "muted")
text.append(" " * pad)
return text
@@ -627,7 +639,8 @@ def nodes_table(
) -> Group:
"""List this node and its known contacts with recency, packets, type, key, and legend.
Our own node is the first row (``★``, name in accent); contacts follow in ``sort`` order.
Our own node is the first row (``★``, name in the white ``you`` style); contacts follow
in ``sort`` order.
A per-type glyph marks each node in the app's shared colours, the name is coloured by how
recently it was last heard (brighter = fresher), and the full key is shown with its
path-hash prefix lit — chopped with an ellipsis only when the terminal is too narrow.
@@ -676,7 +689,8 @@ def nodes_table(
unknown = Text("?", style="muted")
table.add_row(
Text(_SELF[0], style=_SELF[1]),
Text.assemble((self_name, "accent"), (" (you)", "muted")),
# Our own name is the app-wide pure-white "you" style, never a palette hue.
Text.assemble((self_name, "you"), (" (you)", "muted")),
Text("—", style="faint"),
Text("—", style="faint"),
highlighted_hash(self_key, prefix_bytes) if self_key else unknown,
+2 -2
View File
@@ -670,7 +670,7 @@ def test_at_mention_renders_as_name_in_sender_hue() -> None:
"""An ``@[Name]`` token renders as a bare ``@Name`` colored in that sender's hue."""
from datetime import datetime, timezone
from meshterm.ui.chat import _SENDER_COLORS
from meshterm.ui.theme import NAME_COLORS as _SENDER_COLORS
base = datetime(2026, 7, 5, 14, 24, tzinfo=timezone.utc)
conv = Conversation(label="#public", is_channel=True, channel_idx=0)
@@ -908,7 +908,7 @@ def test_channel_self_style_keyed_on_concept_not_label() -> None:
A remote sender who happens to be named 'you' must still get a palette hue, never the
white style reserved for us.
"""
from meshterm.ui.chat import _SENDER_COLORS
from meshterm.ui.theme import NAME_COLORS as _SENDER_COLORS
screen = _channel_screen([])
assert screen._sender_style("you", is_self=True) == "you" # us → white