Chat: a sender label is a name drawn as a path line's chip

A name standing alone above its messages was a coloured word with nothing
to say it was a label — at a glance it read like something someone typed.
It now sits in a chip, and not a chip of its own invention: ui/widgets.
name_chip builds a one-hop PathLine and asks it for its line, so the fill,
the ink, the padding, the rounded cap and the closing point are whatever a
route's segments are wearing today and cannot drift from them. A sender
wears exactly what that node wears as a hop — hue in the fill under dark
ink, the keyless grey where no key places them, and our own messages
headed by the ★ on its neutral grey rather than by the word "you".

Only the group header. An @mention inside a body is part of what was said,
and framing it would put a chip in the middle of a sentence.

Degrading comes free with the borrowing: where the terminal can't draw
separators — the PicoCalc console among them — a path line is arrow text
and one hop is just the name in its hue, which is what a sender label was
before it was framed. The specimen card grows a chips row, which doubles
as a one-hop sample of the route language.

And the unknown sender takes node.unknown rather than muted: one hex apart
here, a whole slot apart on the console, and an unidentified sender is
content you can still act on, not chrome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-08-13 00:58:52 -04:00
1 parent e5c756995e
commit f22ffa5011
7 files changed
+137 -142

No files matched your search

+24 -4
View File
@@ -436,15 +436,22 @@ class ChatScreen(Screen):
return (self._name(message.peer) or message.peer or "?"), message.text
def _group_header(self, sender: str, *, is_self: bool = False) -> Text:
"""Build the sender header that starts a group: the name, chipped, in its hue.
"""Build the sender header that starts a group: the name, drawn as a chip.
The chip is what separates a *label* from the prose under it — a name standing
alone above its messages reads as a tag rather than as a coloured word someone
typed. Only this one is framed: an ``@mention`` inside a body (see
typed. It is a path line's own chip (see :func:`~meshterm.ui.widgets.name_chip`),
so a sender wears exactly what that node wears as a hop in a route, our own
messages included: the ``★``, never our name.
Only this one is framed: an ``@mention`` inside a body (see
:meth:`_render_mentions`) is part of what was said, and inscribing it would put a
chip in the middle of a sentence.
"""
return name_chip(sender, self._sender_style(sender, is_self=is_self))
if is_self:
return name_chip("", you=True)
# ``·`` is a channel line that arrived with no sender prefix — nobody to key on.
return name_chip(sender, None if sender == "·" else self._sender_key(sender))
def _body_lines(
self, body: str, message: ChatMessage, width: int, *, selected: bool = False
@@ -560,10 +567,23 @@ class ChatScreen(Screen):
return "you" # white, out of the per-sender hue range — always easy to spot
if sender == "·":
return "node.unknown"
return name_style(sender, self._sender_key(sender, mention=mention))
def _sender_key(self, sender: str, *, mention: bool = False) -> Optional[str]:
"""The key a sender name resolves back to, or ``None`` when nothing places it.
Contacts first, then the recorder's stored names; in a *direct* thread a sender
label that resolves to nothing falls back to the peer's own key, since the only
two people in the conversation are us and them. A mention names an arbitrary
person, so that fallback must not apply to one.
Shared by the colour (:meth:`_sender_style`) and the chip
(:meth:`_group_header`) so the two can't disagree about who a name is.
"""
key = self._key_of(sender)
if not key and not self._is_channel and not mention:
key = self._peer_key or None
return name_style(sender, key)
return key
# --- input ---------------------------------------------------------------
+6 -5
View File
@@ -116,15 +116,16 @@ def specimen_lines() -> list[RenderableType]:
names.append("me", style="you")
lines.append(names)
# The same names framed — the chat's sender labels. On the console this row shows
# them bare, which is the point: there is no dark grey to field a chip with there.
# The same names as chips — the chat's sender labels, drawn through the path line's
# own segment, so this row is also a one-hop sample of the route language: hue in the
# fill, our end the star, a node nobody can place in the keyless grey.
chips = Text("chips ")
for name, key in _DEMO_NAMES[:2]:
chips.append_text(name_chip(name, name_style(name, key)))
chips.append_text(name_chip(name, key))
chips.append(" ")
chips.append_text(name_chip("me", "you"))
chips.append_text(name_chip("", you=True))
chips.append(" ")
chips.append_text(name_chip("·", "node.unknown"))
chips.append_text(name_chip("·"))
lines.append(chips)
lines.append(Text("heard ").append_text(_ages_row()))
-12
View File
@@ -136,11 +136,6 @@ MESH_THEME = Theme(
# only PicoCalc's footer_fkeys ever asks for them.
"fkey.chip": "bold #ffffff on #475569",
"fkey.chip.shift": "bold #ffffff on #16a34a",
# The field a node name is inscribed on when a surface draws it as a chip (see
# ui.widgets.name_chip): the same neutral dark grey pathline fills our own ``★``
# chip with, so the two chip languages agree. Background only — the ink is always
# the node's own hue, laid over this.
"name.chip": "on #3f3f46",
# The prose voices — what a markdown page's inline marks are drawn in (see
# ui.markdown). Headings borrow the styles the rest of the app already heads
# sections with (``brand``, ``accent``), so only the *body* marks need names of
@@ -317,13 +312,6 @@ MESH_THEME_16 = Theme(
# Shift bank, white text on both.
"fkey.chip": "bold color(15) on color(7)",
"fkey.chip.shift": "bold color(15) on color(2)",
# The name chip's field, which this console cannot actually draw: the palette's
# dark grey is slot 8 and backgrounds stop at 7, so there is no neutral field for
# a coloured name to sit on (the F-key lane's light grey is far too bright to take
# a node hue). The name is drawn bare here instead — ui.widgets.name_chip fields a
# chip only where the platform has the colour for one — and this entry exists so
# the style name resolves on both platforms, as every style name must.
"name.chip": "on color(0)",
# Prose on the console. The page's body text is the default light grey (slot 7),
# so emphasis is the one place the VT's bold-is-brightness rule *is* the design:
# ``md.strong`` says bold on purpose and lands on white, exactly the step up the
+22 -77
View File
@@ -18,7 +18,6 @@ from rich.progress import (
TextColumn,
TimeElapsedColumn,
)
from rich.style import Style
from rich.table import Table
from rich.text import Text
@@ -52,14 +51,8 @@ from .marks import (
LabelRgbOf,
parse_hex,
)
from .pathline import (
POWERLINE_ROUND_CLOSE,
POWERLINE_ROUND_OPEN,
PathLine,
path_line,
)
from .termfont import powerline_full
from .theme import active_theme, glyph, mark_rgb, name_style, node_style, snr_style
from .pathline import SELF_GLYPH, PathHop, PathLine, path_line
from .theme import glyph, mark_rgb, name_style, node_style, snr_style
if TYPE_CHECKING:
from ..core.discovery import DiscoveredDevice
@@ -502,82 +495,34 @@ def highlighted_hash(
return text
#: The theme name of the field a chipped name sits on (defined by both themes).
_CHIP_FILL = "name.chip"
def name_chip(label: str, key: Optional[str] = None, *, you: bool = False) -> Text:
"""A node name drawn as a path line's single chip — THE way a surface *frames* a name.
#: The chip's two end caps, in the two shapes a terminal can give them. The rounded caps
#: (:data:`~meshterm.ui.pathline.POWERLINE_ROUND_OPEN` / ``_CLOSE``) are the lozenge a
#: path line already closes on, so a chipped name and a chipped hop round the same way —
#: but they live in a full Nerd Font patch's extended block. Without one the caps fall
#: back to the half blocks: ``▐`` fills a cell's right half and ``▌`` its left, so drawn in
#: the fill over the page they widen the field by half a cell at each end and taper it,
#: exactly the way the device picker's Bluetooth badge is rounded off (see
#: :func:`~meshterm.ui.device_picker._type_cell`). Two cells of overhead either way.
_ROUND_CAPS = (POWERLINE_ROUND_OPEN, POWERLINE_ROUND_CLOSE)
_HALF_CAPS = ("▐", "▌")
Not an imitation of the chip language but a use of it: this builds a one-hop
:class:`~meshterm.ui.pathline.PathLine` and asks it for its line, so the fill, the
ink, the padding, the rounded cap and the closing point are all whatever a route's
segments are wearing today, and can never drift from them (JP, 2026-08-12). The colour
reads exactly as it does in a route — the *fill* carries the node's hue with dark ink
on it, a node no key can place takes the keyless grey, and our own end is the map's
``★`` on its neutral dark grey rather than a name.
def name_chip(label: str, style: str) -> Text:
"""A node name inscribed in a chip — THE way a surface *frames* a name.
The name keeps its own hue (the caller passes whatever style it would have drawn the
name in — a key-derived palette colour, the ``you`` white, the ``node.unknown`` grey)
and that hue becomes the chip's ink, laid on the neutral dark grey field
:data:`_CHIP_FILL` names. Nothing about which colour a name gets changes here; the
chip only gives the colour something to sit on, so a sender label reads as a tag
rather than as a stray coloured word above a message.
The ink is resolved to a concrete :class:`~rich.style.Style` and *added* to the fill
rather than concatenated as text: Rich silently drops a style string that mixes a
theme name with anything else (``"node.unknown on #3f3f46"`` renders as plain text),
which is exactly the shape a naive f-string would build here.
A platform whose console cannot field the chip draws the bare name instead — see
:func:`_bind_chip`, and the ``name.chip`` note in the 16-slot theme.
Degrading is inherited too: where the terminal can't draw powerline separators, a path
line is arrow-mode text and a lone hop is simply the name in its own hue — which is
what a sender label was before it was framed, and what the PicoCalc console (whose font
carries no separator glyph) goes on drawing.
Args:
label: The name as it should read (already resolved; this never renames it).
style: The style the name would wear uncipped — a theme name or a literal
``"bold #rrggbb"``, either way the node's own colour.
Ignored when ``you`` — our own end is the star, never our name.
key: Any known prefix of the node's key, which picks the hue. ``None`` is a node
we can't place: the keyless grey, colour being reserved for keyed identities.
you: This is our own node — the ``★``, exactly as a route draws our end of it.
Returns:
The chip as a :class:`Text`, or the plain styled name where chips aren't drawn.
The chip as a one-line :class:`Text`.
"""
if not _chip_fielded:
return Text(label, style=style)
fill = active_theme().styles[_CHIP_FILL]
themed = active_theme().styles.get(style)
ink = themed if themed is not None else Style.parse(style)
# The caps are the fill drawn *over the page* — foreground only, so the half of the
# cell that isn't chip stays whatever the row behind it is.
edge = Style(color=fill.bgcolor)
# Asked per call rather than bound: the verdict is cached after the first ask, and
# binding it would move termfont's installed-font scan onto every import of this
# module — including a bare ``meshterm --help``, which draws nothing at all.
open_cap, close_cap = _ROUND_CAPS if powerline_full() else _HALF_CAPS
text = Text()
text.append(open_cap, style=edge)
text.append(label, style=ink + fill)
text.append(close_cap, style=edge)
return text
#: Whether this platform draws the chip at all, rather than the bare name (bound below).
_chip_fielded: bool = True
@on_platform
def _bind_chip(platform: Platform) -> None:
"""Decide whether the platform can field a chip behind a name.
The chip is a *field* behind a name, and the 16-slot console has none to offer: its
one dark grey is slot 8 and backgrounds stop at 7, so the choice there is between a
name on light grey (which drowns the hue the chip exists to carry) and no chip at
all. Truecolor is the honest proxy for "this platform has a neutral dark grey", the
same test the heat gradient binds on.
"""
global _chip_fielded
_chip_fielded = platform.truecolor
hop = PathHop(SELF_GLYPH, you=True) if you else PathHop(label, key=key or None)
return PathLine([hop]).text()
def _shorten_hash(value: str, hash_bytes: Optional[int]) -> str:
+19 -1
View File
@@ -19,7 +19,7 @@ always see the colours the app really emits.
from __future__ import annotations
import re
from typing import Iterable, Iterator, Union
from typing import Callable, Iterable, Iterator, Union
import pytest
@@ -53,6 +53,24 @@ def _plain_path_rendering(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
powerline_support.cache_clear()
@pytest.fixture
def powerline(monkeypatch: pytest.MonkeyPatch) -> Callable[[bool], None]:
"""Pin the powerline verdict for a test that is *about* chips.
The suite runs with ``MESHTERM_POWERLINE=0`` so screen assertions don't change with
the developer's glass (see the module docstring); a test of the chip language itself
needs the other answer, and asks for it through the supported override rather than by
reaching into the widget. ``monkeypatch`` restores the env and the autouse fixture
above clears the memoized verdict either side, so nothing leaks.
"""
def _set(on: bool) -> None:
monkeypatch.setenv("MESHTERM_POWERLINE", "1" if on else "0")
powerline_support.cache_clear()
return _set
@pytest.fixture(autouse=True)
def _reset_platform() -> Iterator[None]:
"""Every test starts and ends on :data:`~meshterm.platforms.REGULAR`.
+12 -12
View File
@@ -976,7 +976,8 @@ def test_channel_transcript_groups_by_sender() -> None:
rendered = _strip_ansi("\n".join(screen._render_grouped(80)))
assert rendered.count("Alice") == 1 # the two Alice messages share one header
assert "Bob" in rendered and "you" in rendered
# Our own group is headed by the ★ a route draws our end with, never by our name.
assert "Bob" in rendered and "★" in rendered
assert "hi" in rendered and "again" in rendered # bodies present, prefix stripped
assert "Alice: hi" not in rendered # the raw name prefix is lifted into the header
# Each message keeps its own timestamp on its line, even when grouped under one sender.
@@ -986,10 +987,11 @@ def test_channel_transcript_groups_by_sender() -> None:
assert stamps[0] != stamps[1] # grouped Alice messages show distinct times
def test_transcript_inscribes_the_sender_label_but_never_a_mention() -> None:
"""The name that *opens* a group is a tag; the same name inside a body is prose."""
def test_transcript_inscribes_the_sender_label_but_never_a_mention(powerline) -> None:
"""The name that *opens* a group is a chip; the same name inside a body is prose."""
from meshterm.ui.widgets import name_chip
powerline(True) # this is a test *about* the chip, so ask for a glass that draws one
conv = Conversation(label="#public", is_channel=True, channel_idx=0)
messages = [
ChatMessage(text="Alice: @[Bob] you around?", is_channel=True, channel_idx=0),
@@ -997,14 +999,11 @@ def test_transcript_inscribes_the_sender_label_but_never_a_mention() -> None:
screen = ChatScreen(conv, messages, send=None, names={}, session=_StubSession())
rendered = _strip_ansi("\n".join(screen._render_grouped(80)))
# Built through the widget rather than spelled out: which caps it uses depends on the
# font the session found, and the point here is the framing, not the glyph.
def chipped(name: str) -> str:
return name_chip(name, "bold #ffffff").plain
assert chipped("Alice") in rendered # the header, inscribed
assert "@Bob" in rendered # the mention, as it was typed
assert chipped("Bob") not in rendered # …and not framed mid-sentence
# Built through the widget rather than spelled out: the caps are the path line's, and
# what is being asserted here is the framing, not the glyph.
assert name_chip("Alice").plain in rendered # the header, as a chip
assert "@Bob" in rendered # the mention, as it was typed
assert name_chip("Bob").plain not in rendered # …and never framed mid-sentence
def test_unidentified_sender_takes_the_node_grey_not_the_chrome_grey() -> None:
@@ -1230,7 +1229,8 @@ def test_channel_own_messages_do_not_merge_with_remote_namesake() -> None:
]
screen = _channel_screen(messages)
rendered = _strip_ansi("\n".join(screen._render_grouped(80)))
assert rendered.count("you") == 2 # two separate headers, not one merged group
# Two headers, and not merely un-merged: theirs is the name they sent, ours is the ★.
assert rendered.count("you") == 1 and rendered.count("★") == 1
def _channel_screen(messages, session=None, key_of=None) -> ChatScreen:
+54 -31
View File
@@ -7,7 +7,16 @@ and every caller inherits it.
from __future__ import annotations
from meshterm.platforms import PICOCALC, REGULAR, set_platform
from rich.style import Style
from rich.text import Text
from meshterm.ui.pathline import (
POWERLINE_ROUND_CLOSE,
POWERLINE_ROUND_OPEN,
PathHop,
PathLine,
)
from meshterm.ui.theme import node_style
from meshterm.ui.widgets import (
_format_age,
format_ago,
@@ -17,6 +26,11 @@ from meshterm.ui.widgets import (
)
def _span_style(text: Text, run: str) -> Style:
"""The style over ``run`` in ``text``, parsed — pathline spans carry style *strings*."""
return Style.parse(next(str(s.style) for s in text.spans if text.plain[s.start : s.end] == run))
def test_format_age_is_the_bare_column_form() -> None:
"""The lane form stays suffix-free at every magnitude, for aligned age columns."""
assert _format_age(None) == "never"
@@ -192,40 +206,49 @@ def test_revisit_note_is_absent_when_nothing_repeats() -> None:
assert revisit_note(()) is None
def test_name_chip_frames_the_name_without_recolouring_it() -> None:
"""The chip adds a field; the hue it fields is exactly the one it was handed."""
chip = name_chip("Alice", "bold #ff00aa")
def test_name_chip_is_a_path_line_of_one_hop() -> None:
"""The framing isn't imitated — it *is* the route language, so it can't drift from it."""
chip = name_chip("Alice", "a1b2")
hop = PathLine([PathHop("Alice", key="a1b2")]).text()
assert chip.plain[1:-1] == "Alice" # a cap either side, the name untouched between
styles = {chip.plain[s.start : s.end]: s.style for s in chip.spans}
assert styles["Alice"].color.name == "#ff00aa" # its own hue, still
assert styles["Alice"].bgcolor is not None # now with something under it
# The caps are the fill drawn over the page — foreground only, so the half-cell that
# isn't chip keeps whatever the row behind it is.
for cap in (chip.plain[0], chip.plain[-1]):
assert styles[cap].bgcolor is None
assert styles[cap].color == styles["Alice"].bgcolor
assert chip.plain == hop.plain
assert [(s.start, s.end, str(s.style)) for s in chip.spans] == [
(s.start, s.end, str(s.style)) for s in hop.spans
]
def test_name_chip_takes_a_theme_name_as_readily_as_a_hex() -> None:
"""``node.unknown``/``you`` arrive as theme names — the fill must not flatten them.
def test_name_chip_wears_the_hue_as_its_fill_padded_like_every_chip(powerline) -> None:
"""A route's colour scheme: the node's hue fills the chip, the ink on it is dark."""
powerline(True)
chip = name_chip("Alice", "a1b2")
Rich drops a style string that mixes a theme name with anything else, so a chip built
by concatenation renders the name unstyled. This is that regression, pinned.
"""
chip = name_chip("·", "node.unknown")
style = next(s.style for s in chip.spans if chip.plain[s.start : s.end] == "·")
assert style.color is not None and style.bgcolor is not None
assert style.color.name != style.bgcolor.name # not the invisible-on-itself case
assert chip.plain.strip(POWERLINE_ROUND_OPEN + POWERLINE_ROUND_CLOSE) == " Alice "
ink = _span_style(chip, "Alice")
assert ink.bgcolor.name == node_style("a1b2").removeprefix("bold ") # the hue
assert ink.color.name == "#0f172a" # the chip ink every hop is lettered in
def test_name_chip_draws_bare_where_the_console_has_no_field_for_it() -> None:
"""PicoCalc's greys stop at slot 7 as backgrounds — so the name goes unframed there."""
set_platform(PICOCALC)
try:
chip = name_chip("Alice", "bold #ff00aa")
finally:
set_platform(REGULAR)
def test_name_chip_draws_us_as_the_star_never_as_our_name(powerline) -> None:
"""Our end of a route is the map's own marker; a sender label is no different."""
powerline(True)
chip = name_chip("you", you=True)
assert chip.plain == "Alice" # no caps, no field — and no tofu on the console font
assert "★" in chip.plain and "you" not in chip.plain
assert _span_style(chip, "★").bgcolor.name == "#3f3f46" # neutral grey, not a hue
def test_name_chip_greys_a_node_it_cannot_place(powerline) -> None:
"""No key, no hue — colour is reserved for keyed identities, in a chip as in a route."""
powerline(True)
keyless = name_chip("·")
assert _span_style(keyless, "·").bgcolor.name == "#64748b" # the keyless grey
def test_name_chip_falls_back_to_the_bare_name_without_powerline(powerline) -> None:
"""No separator glyph to draw a chip with — the PicoCalc's console font among them."""
powerline(False)
chip = name_chip("Alice", "a1b2")
assert chip.plain == "Alice" # exactly what an arrow-mode path line makes of one hop
assert str(next(iter(chip.spans)).style) == node_style("a1b2") # still its own hue