Enliven the conversation list and keep section headings in view

Color-code the chat picker rows: a contact's dot takes that person's
chat hue (filled once talked-to, hollow before), previews light their
sender names and @mentions in-hue, and a relative age column sits
between the unread badge and the message text.

Pin a grouped list's section heading to the top row once it scrolls off
(sticky header in the frame), add PageUp/PageDown to SelectScreen, and
drop arrow wraparound in the conversation picker so it doesn't jump
across the section dividers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 4.8 committed 2026-07-08 20:03:31 -04:00
1 parent aa537d5503
commit 8906e4ed53
9 files changed
+338 -78

No files matched your search

+110 -42
View File
@@ -28,7 +28,8 @@ from ..core.channels import (
)
from ..core.connection import Device
from ..core.events import EventKind, MeshEvent
from ..core.models import ChatMessage, Contact, Conversation
from ..core.models import ChatMessage, Contact, Conversation, utcnow
from ..ui.chat import _MENTION, _sender_hue, _split_channel_sender
from ..ui.tui import Choice, Separator
from ..ui.widgets import channel_glyph
from .base import Tool, ToolResult, register
@@ -117,11 +118,11 @@ class ChatTool(Tool):
lasts = ctx.repo.last_chat_messages()
live = _LiveLasts(ctx, seed=lasts)
items: list = [Separator("── Channels ──")]
items: list = [Separator("── 📡 Channels ──")]
for conversation in channels:
items.append(Choice(title=_row_title(ctx, conversation, live), value=conversation))
items.append(Separator("── Direct ──"))
items.append(Separator("── 👤 Direct ──"))
if contacts:
# List contacts by recency — those with messages first, newest exchange at the
# top — then the never-contacted ones alphabetically (see _recency_key).
@@ -148,7 +149,7 @@ class ChatTool(Tool):
None,
)
choice = await ctx.ui.select(
"Chat — pick a conversation", items, default=default
"Chat — pick a conversation", items, default=default, wrap=False
)
if choice in (None, "__back__"):
return None
@@ -492,10 +493,13 @@ class _LiveLasts:
#: Column width (display cells) the conversation label is padded/ellipsized to, so the unread
#: badge and message preview line up in fixed lanes down the picker.
_LABEL_WIDTH = 22
#: Width of the unread-badge lane between the label and the preview (fits ``● 999``).
#: Width of the unread-badge lane between the label and the age (fits ``● 999``).
_BADGE_WIDTH = 5
#: Width of the relative-age lane between the badge and the preview (right-aligned; fits ``now``
#: and two-digit spans like ``59m`` / ``23h``), so every row's message text starts in one column.
_AGE_WIDTH = 3
#: Longest message preview shown before it is ellipsized.
_PREVIEW_WIDTH = 42
_PREVIEW_WIDTH = 40
def _row_title(
@@ -521,13 +525,15 @@ def _row_title(
def _title(
ctx: AppContext, conversation: Conversation, lasts: "_LiveLasts"
) -> Union[str, Text]:
"""Build a picker row as fixed-width columns: label, unread badge, message preview.
"""Build a picker row as fixed-width, colour-coded lanes.
Alignment carries the readability: the label is padded to a fixed lane, the unread ``●``
badge sits in its own lane, and the preview lines up across every row regardless of how
long the names or counts are. When there is unread, the row is returned as a Rich
:class:`~rich.text.Text` so the ``●`` glyph alone can be tinted red; otherwise a plain
string suffices.
Alignment carries the readability — marker, label, unread badge, relative age, and preview
each sit in their own lane, so every row's message text starts in the same column. Colour is
kept light and purposeful: the name stays in the base colour, a contact's leading dot is
tinted in that person's chat hue, the unread ``●`` badge is red, the age is muted, and the
preview mutes its body while lighting sender names and ``@mentions`` in their hue — the same
colours the live transcript uses. The row is always a Rich :class:`~rich.text.Text` so those
spans survive under the select screen's row highlight.
Args:
ctx: Shared application context (for the live unread count).
@@ -535,45 +541,107 @@ def _title(
lasts: The self-refreshing latest-message view.
Returns:
The row title — a plain ``str``, or a ``Text`` with a red ``●`` when unread.
The row title as a styled :class:`~rich.text.Text`.
"""
unread = ctx.chat.unread(conversation.key)
badge = f"● {unread}" if unread else ""
last = lasts.get(conversation.key)
preview = _preview(last) if last is not None else ""
# A channel leads with its openness marker (# / 🌐 / 🔒); a direct chat with a person glyph
# — 💬 once we've exchanged messages, 👤 for a contact we haven't talked to yet. Every glyph
# is one double-width cell, so the label lane still lines up across rows.
if conversation.is_channel:
glyph = channel_glyph(conversation.label, conversation.secret)
text = Text(no_wrap=True, overflow="ellipsis")
_append_marker(text, conversation, last)
text.append(_fit(conversation.label, _LABEL_WIDTH))
text.append(" ")
# Unread badge lane (_BADGE_WIDTH cells): a red ● with the count in warn, or blank filler so
# the following lanes still line up on rows with nothing unread.
if unread:
text.append("●", style="err")
text.append(f" {unread}".ljust(_BADGE_WIDTH - 1), style="warn")
else:
glyph = "💬" if last is not None else "👤"
line = f"{glyph} {_fit(conversation.label, _LABEL_WIDTH)} {badge:<{_BADGE_WIDTH}} {preview}".rstrip()
if not unread:
return line
text = Text(line)
dot = line.index("●") # only the badge carries this glyph; colour just it red
text.stylize("err", dot, dot + 1)
text.append(" " * _BADGE_WIDTH)
# Relative-age lane (right-aligned) sits between the badge and the message text, so the ages
# stack in one tidy column and the previews all start at the same place.
age = _ago(last.created_at) if last is not None else ""
text.append(" ")
text.append(f"{age:>{_AGE_WIDTH}}", style="muted")
text.append(" ")
if last is not None:
text.append_text(_preview_text(last))
return text
def _preview(last: ChatMessage, *, width: int = _PREVIEW_WIDTH) -> str:
"""One-line preview of the latest message, ``you:``-prefixed when we sent it.
def _append_marker(text: Text, conversation: Conversation, last: Optional[ChatMessage]) -> None:
"""Prepend the row's leading marker (3 display cells) — a channel glyph or a contact dot.
Only our own outbound messages get an author prefix. Inbound channel messages already
carry the sender's node name inline in their text (the firmware embeds it), and an inbound
direct message's author is the row's own label — so neither needs one added here.
Args:
last: The most recent message in the conversation.
width: The column budget before the preview is ellipsized.
Returns:
A single-line, length-bounded preview string.
A channel keeps its openness marker (# / 🌐 / 🔒). A contact gets a small circle tinted in
that person's chat hue — filled (``●``) once we've exchanged messages, a hollow ring (``○``)
before any — so the colour identifies the person and the hollow-vs-filled shape marks whether
there's history, while the name itself stays in the base colour. The contact dot is padded to
the same width as a channel's double-cell glyph so the labels line up across both sections.
"""
who = "you: " if last.outbound else ""
body = f"{who}{last.text}".replace("\n", " ")
return body[: width - 1] + "…" if len(body) > width else body
if conversation.is_channel:
text.append(f"{channel_glyph(conversation.label, conversation.secret)} ")
else:
dot = "●" if last is not None else "○"
text.append(dot, style=_sender_hue(conversation.label))
text.append(" ")
def _preview_text(last: ChatMessage) -> Text:
"""A muted last-message preview with sender names and ``@mentions`` lit in their chat hue.
Mirrors the live transcript: our own messages get a ``you:`` prefix, an inbound channel
message's inline ``Name:`` sender is coloured in that sender's hue, and every ``@[Name]``
mention reads as a bare ``@Name`` in the mentioned person's hue — so the list and the chat
speak the same colour language. The result is clipped to :data:`_PREVIEW_WIDTH` cells.
"""
body_raw = last.text.replace("\n", " ")
text = Text()
if last.outbound:
text.append("you: ", style="accent")
_append_body(text, body_raw)
elif last.is_channel:
name, body = _split_channel_sender(body_raw)
if name is not None:
text.append(name, style=_sender_hue(name))
text.append(": ", style="muted")
_append_body(text, body)
else:
_append_body(text, body_raw)
else:
_append_body(text, body_raw)
text.truncate(_PREVIEW_WIDTH, overflow="ellipsis")
return text
def _append_body(text: Text, body: str) -> None:
"""Append ``body`` to ``text``, muted, with each ``@[Name]`` mention drawn in the name's hue."""
pos = 0
for match in _MENTION.finditer(body):
if match.start() > pos:
text.append(body[pos : match.start()], style="muted")
name = match.group(1)
text.append(f"@{name}", style=_sender_hue(name))
pos = match.end()
if pos < len(body):
text.append(body[pos:], style="muted")
def _ago(when: Any) -> str:
"""A compact relative age for a message time — ``now``, ``5m``, ``3h``, ``2d``, ``4w``.
Tolerates a naive timestamp (assumed UTC) so a stray one from the wire can't crash the
picker on the aware/naive subtraction.
"""
if getattr(when, "tzinfo", None) is None:
return ""
secs = max(0.0, (utcnow() - when).total_seconds())
if secs < 60:
return "now"
if secs < 3600:
return f"{int(secs // 60)}m"
if secs < 86400:
return f"{int(secs // 3600)}h"
if secs < 604800:
return f"{int(secs // 86400)}d"
return f"{int(secs // 604800)}w"
def _fit(text: str, width: int) -> str:
+11 -1
View File
@@ -90,6 +90,16 @@ _BYTES_YELLOW = "bold #fde047"
_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.
"""
return _SENDER_COLORS[sum(map(ord, sender)) % len(_SENDER_COLORS)]
def _split_channel_sender(text: str) -> tuple[Optional[str], str]:
"""Split a channel message into ``(sender_name, body)`` when it carries a name prefix.
@@ -436,7 +446,7 @@ class ChatScreen(Screen):
return "you" # white, out of the per-sender hue range — always easy to spot
if sender == "·":
return "muted"
return _SENDER_COLORS[sum(map(ord, sender)) % len(_SENDER_COLORS)]
return _sender_hue(sender)
# --- input ---------------------------------------------------------------
+10 -4
View File
@@ -53,7 +53,9 @@ class Ui:
"""Return a progress context manager exposing ``add_task``/``advance``/``update``."""
raise NotImplementedError
async def select(self, title: str, items: list, *, default: Any = None) -> Any:
async def select(
self, title: str, items: list, *, default: Any = None, wrap: bool = True
) -> Any:
"""Prompt the user to choose one item; return its value or ``None`` if cancelled."""
raise NotImplementedError
@@ -164,7 +166,9 @@ class PlainUi(Ui):
"""Build the error raised if a rich prompt is reached on the non-interactive path."""
return RuntimeError("interactive prompts are only available in the menu")
async def select(self, title: str, items: list, *, default: Any = None) -> Any:
async def select(
self, title: str, items: list, *, default: Any = None, wrap: bool = True
) -> Any:
"""Unsupported in scripted CLI mode."""
raise self._no_prompt()
@@ -313,9 +317,11 @@ class TuiUi(Ui):
# --- input ---------------------------------------------------------------
async def select(self, title: str, items: list, *, default: Any = None) -> Any:
async def select(
self, title: str, items: list, *, default: Any = None, wrap: bool = True
) -> Any:
"""Delegate to the session's select screen."""
return await self.session.select(title, items, default=default)
return await self.session.select(title, items, default=default, wrap=wrap)
async def select_startup(
self,
+34 -10
View File
@@ -23,7 +23,10 @@ def _visible_slice(screen: Screen, lines: list[str], viewport: int) -> tuple[lis
Keeps the screen's cursor line in view (for select/text screens) and clamps the scroll
offset to the content, then pads the slice to exactly ``viewport`` rows so the panel
always fills its allotted height.
always fills its allotted height. When the screen offers a sticky header (a grouped list's
section heading that has scrolled off), it is pinned to the top row: that reserves one row,
so the cursor is kept within the remaining ``viewport - 1`` and the bottom clamp is relaxed
by one so the final content row can still reach the last visible line.
Args:
screen: The screen being rendered (its ``scroll`` is adjusted in place).
@@ -37,16 +40,37 @@ def _visible_slice(screen: Screen, lines: list[str], viewport: int) -> tuple[lis
if isinstance(screen, ScrollScreen):
screen.note_viewport(viewport)
cursor = screen.cursor_line()
if cursor is not None:
if cursor < screen.scroll:
screen.scroll = cursor
elif cursor >= screen.scroll + viewport:
screen.scroll = cursor - viewport + 1
screen.scroll = max(0, min(screen.scroll, max(0, total - viewport)))
visible = lines[screen.scroll : screen.scroll + viewport]
more_above = screen.scroll > 0
more_below = screen.scroll + viewport < total
scroll = screen.scroll
if cursor is not None:
if cursor < scroll:
scroll = cursor
elif cursor >= scroll + viewport:
scroll = cursor - viewport + 1
scroll = max(0, min(scroll, max(0, total - viewport)))
# A pinned section heading takes the top row, leaving one fewer for content; nudge the
# scroll down if the cursor would fall in that reserved row, then re-check the heading
# (crossing a section boundary can change which one is pinned, or drop it entirely).
sticky = screen.sticky_header(scroll) if scroll > 0 else None
if sticky is not None:
cap = max(1, viewport - 1)
if cursor is not None and cursor >= scroll + cap:
scroll = min(cursor - cap + 1, max(0, total - cap))
sticky = screen.sticky_header(scroll) if scroll > 0 else None
screen.scroll = scroll
if sticky is not None:
cap = max(1, viewport - 1)
visible = [sticky] + lines[scroll : scroll + cap]
more_below = scroll + cap < total
visible = visible + [""] * (viewport - len(visible))
return visible, True, more_below
visible = lines[scroll : scroll + viewport]
more_above = scroll > 0
more_below = scroll + viewport < total
visible = visible + [""] * (viewport - len(visible))
return visible, more_above, more_below
+9
View File
@@ -78,6 +78,15 @@ class Screen:
"""
return None
def sticky_header(self, scroll: int) -> Optional[str]:
"""An already-rendered body line to pin to the top row once ``scroll`` moves past it.
Lets a grouped list keep its current section heading in view after the heading itself
has scrolled off. Returns an ANSI line (e.g. a section separator) to pin, or ``None``
for no pinned header. ``scroll`` is the offset the body is about to be sliced at.
"""
return None
# --- input ---------------------------------------------------------------
def handle(self, action: str, data: str = "") -> None:
+46 -3
View File
@@ -56,6 +56,10 @@ class Separator:
Item = "Choice | Separator"
#: How many rows PageUp/PageDown move the highlight. A fixed step (the screen isn't told the
#: viewport height) that keeps paging predictable, matching the chat transcript's own paging.
_PAGE = 10
class SelectScreen(Screen):
"""A grouped, filterable, single-choice list.
@@ -72,6 +76,7 @@ class SelectScreen(Screen):
default: Any = None,
footer_hint: str = "↑↓ move · type to filter · Enter select · Esc back",
filterable: bool = True,
wrap: bool = True,
) -> None:
"""Build a select screen.
@@ -82,11 +87,15 @@ class SelectScreen(Screen):
footer_hint: Footer key hint.
filterable: Whether typing narrows the list. Off for short, fixed lists (e.g.
the startup device picker) where type-to-filter would only get in the way.
wrap: Whether the highlight wraps around the ends (Down from the last row jumps
to the first, and vice versa). Off for grouped lists where wrapping across the
section headings reads as a jarring jump rather than continuing to scroll.
"""
super().__init__()
self.title = title
self.footer_hint = footer_hint
self._filterable = filterable
self._wrap = wrap
self._items = items
self._filter = ""
# Index into the currently-selectable (filtered) choices.
@@ -130,11 +139,16 @@ class SelectScreen(Screen):
selected = choices[self._index] if choices else None
lines: list[str] = []
# (body-line index, rendered line) for each separator, so a section heading that
# scrolls off can be re-pinned to the top row (see :meth:`sticky_header`).
self._separator_lines: list[tuple[int, str]] = []
if self._filter:
lines.append(render_to_ansi(Text(f"/{self._filter}", style="warn"), width))
for item in rows:
if isinstance(item, Separator):
lines.append(render_to_ansi(Text(item.title, style="muted"), width))
sep = render_to_ansi(Text(item.title, style="muted"), width)
self._separator_lines.append((len(lines), sep))
lines.append(sep)
continue
is_sel = item is selected
pointer = "❯ " if is_sel else " "
@@ -160,15 +174,44 @@ class SelectScreen(Screen):
"""Return the body line index of the highlighted row."""
return getattr(self, "_cursor", None)
def sticky_header(self, scroll: int) -> Optional[str]:
"""The section heading governing the top visible row, when it has scrolled off.
Returns the rendered separator line for the group the first visible row belongs to,
so the "Channels"/"Direct"-style heading stays pinned as you scroll down into a long
section. Returns ``None`` when that heading is itself still on screen (nothing to pin)
or while filtering (headings are hidden then).
"""
governing: Optional[str] = None
governing_at = -1
for idx, line in getattr(self, "_separator_lines", ()):
if idx <= scroll:
governing, governing_at = line, idx
else:
break
if governing is None or governing_at == scroll:
return None # no heading above, or it's already the top visible row
return governing
# --- input ---------------------------------------------------------------
def handle(self, action: str, data: str = "") -> None:
"""Move the highlight, edit the filter, or commit/cancel the selection."""
choices = self._choices()
if action == "up":
self._index = (self._index - 1) % len(choices) if choices else 0
if choices:
self._index = (self._index - 1) % len(choices) if self._wrap else max(
0, self._index - 1
)
elif action == "down":
self._index = (self._index + 1) % len(choices) if choices else 0
if choices:
self._index = (self._index + 1) % len(choices) if self._wrap else min(
len(choices) - 1, self._index + 1
)
elif action == "pageup":
self._index = max(0, self._index - _PAGE)
elif action == "pagedown":
self._index = min(len(choices) - 1, self._index + _PAGE) if choices else 0
elif action == "home":
self._index = 0
elif action == "end":
+6 -2
View File
@@ -125,9 +125,13 @@ class TuiSession:
finally:
self.pop(screen)
async def select(self, title: str, items: list, *, default: Any = None) -> Any:
async def select(
self, title: str, items: list, *, default: Any = None, wrap: bool = True
) -> Any:
"""Show a select screen; return the chosen value or ``None`` if cancelled."""
result = await self.run_screen(SelectScreen(title, items, default=default))
result = await self.run_screen(
SelectScreen(title, items, default=default, wrap=wrap)
)
return None if result is CANCEL else result
async def select_startup(
+43 -16
View File
@@ -33,7 +33,7 @@ from meshterm.core.models import (
from meshterm.persistence.repository import Repository
from meshterm.services.chat_service import ChatService
from meshterm.services.event_hub import EventHub
from meshterm.tools.chat import _LiveLasts, _preview, _title
from meshterm.tools.chat import _PREVIEW_WIDTH, _LiveLasts, _preview_text, _title
from meshterm.ui.chat import ChatScreen
from meshterm.ui.tui.screen import CANCEL
@@ -239,18 +239,29 @@ def test_preview_prefixes_own_messages_only() -> None:
synthesized (that would double it), and a direct message's author is the row label.
"""
chan_in = ChatMessage(text="Bob: hi", is_channel=True, channel_idx=0) # sender inline
assert _preview(chan_in) == "Bob: hi"
assert _preview_text(chan_in).plain == "Bob: hi"
dm_in = ChatMessage(text="hey", peer="aa", peer_name="Bob")
assert _preview(dm_in) == "hey"
assert _preview_text(dm_in).plain == "hey"
mine = ChatMessage(text="yo", outbound=True, is_channel=True, channel_idx=0)
assert _preview(mine) == "you: yo"
assert _preview_text(mine).plain == "you: yo"
def test_preview_colours_channel_sender_and_mentions() -> None:
"""A channel preview lights its inline sender name and any ``@[Name]`` mention in a hue."""
from meshterm.ui.chat import _sender_hue
msg = ChatMessage(text="Bob: hi @[Alice]", is_channel=True, channel_idx=0)
preview = _preview_text(msg)
assert preview.plain == "Bob: hi @Alice" # the mention's brackets are dropped for display
styles = {span.style for span in preview.spans}
assert _sender_hue("Bob") in styles and _sender_hue("Alice") in styles
def test_preview_ellipsizes_long_text() -> None:
"""An over-long preview is clipped to the width budget with a trailing ellipsis."""
long = ChatMessage(text="x" * 100, is_channel=True, channel_idx=0)
out = _preview(long, width=10)
assert len(out) == 10 and out.endswith("…")
out = _preview_text(long).plain
assert len(out) == _PREVIEW_WIDTH and out.endswith("…")
def test_title_shows_badge_and_author_preview(repo: Repository) -> None:
@@ -266,7 +277,7 @@ def test_title_shows_badge_and_author_preview(repo: Repository) -> None:
def test_title_reddens_only_the_unread_dot(repo: Repository) -> None:
"""With unread the row is a Text whose ``●`` glyph (only) is styled red; else a plain str."""
"""With unread the row's ``●`` glyph (only) is styled red; with none there is no dot."""
from rich.text import Text
conv = Conversation(label="General", is_channel=True, channel_idx=0, channel_id="c0")
@@ -279,7 +290,9 @@ def test_title_reddens_only_the_unread_dot(repo: Repository) -> None:
assert reddened and all(span.end - span.start == 1 for span in reddened) # just the glyph
read = _title(_RowCtx(repo, unread={}), conv, {})
assert isinstance(read, str) # no badge -> plain string, no styling
assert isinstance(read, Text) # always a Text now, so its spans can carry the row's colour
assert "●" not in read.plain # nothing unread -> no badge dot
assert not any(span.style == "err" for span in read.spans)
def test_title_preview_column_aligns_regardless_of_label_length(repo: Repository) -> None:
@@ -289,15 +302,15 @@ def test_title_preview_column_aligns_regardless_of_label_length(repo: Repository
long = Conversation(label="A much longer channel name here", is_channel=True, channel_idx=1, channel_id="c1")
m0 = ChatMessage(text="X: hello", is_channel=True, channel_id="c0")
m1 = ChatMessage(text="Y: hello", is_channel=True, channel_id="c1")
l0 = _title(ctx, short, {"chan:c0": m0})
l1 = _title(ctx, long, {"chan:c1": m1})
l0 = _title(ctx, short, {"chan:c0": m0}).plain
l1 = _title(ctx, long, {"chan:c1": m1}).plain
assert l0.index("X: hello") == l1.index("Y: hello")
def test_title_leads_with_openness_glyph(repo: Repository) -> None:
"""Channel rows lead with an openness glyph: # name-derived, 🌐 fixed-key public, 🔒 private."""
ctx = _RowCtx(repo)
head = lambda conv: _title(ctx, conv, {}).split(" ", 1)[0]
head = lambda conv: _title(ctx, conv, {}).plain.split(" ", 1)[0]
named = Conversation(
label="#general", is_channel=True, channel_id="c0", secret=derive_secret("#general")
)
@@ -312,16 +325,30 @@ def test_title_leads_with_openness_glyph(repo: Repository) -> None:
assert head(private) == "🔒"
def test_title_contact_glyph_reflects_conversation_history(repo: Repository) -> None:
"""A contact shows 💬 once we've exchanged messages, 👤 before any conversation exists."""
def test_title_contact_dot_reflects_conversation_history(repo: Repository) -> None:
"""A contact's dot is filled ● once we've talked, hollow ○ before — always in her hue."""
from meshterm.ui.chat import _sender_hue
ctx = _RowCtx(repo)
contact = Conversation(
label="Alice", is_channel=False, contact=Contact(name="Alice", public_key="d4" + "0" * 62)
)
head = lambda lasts: _title(ctx, contact, lasts).split(" ", 1)[0]
assert head({}) == "👤" # no history yet — a contact we haven't talked to
head = lambda lasts: _title(ctx, contact, lasts).plain.split(" ", 1)[0]
def dot_is_hued(row) -> bool:
return any(
span.style == _sender_hue("Alice") and span.start == 0 and span.end == 1
for span in row.spans
)
# No history yet — a hollow ring, but still tinted in Alice's stable chat hue.
fresh = _title(ctx, contact, {})
assert fresh.plain.startswith("○") and dot_is_hued(fresh)
# Once we've exchanged messages the same-hued dot fills in.
last = ChatMessage(text="hi", peer=contact.peer)
assert head({contact.key: last}) == "💬" # we've exchanged messages
talked = _title(ctx, contact, {contact.key: last})
assert talked.plain.startswith("●") and dot_is_hued(talked)
# -- chat service -------------------------------------------------------------
+69
View File
@@ -142,6 +142,75 @@ def test_select_filter_matches_callable_title() -> None:
assert _run(screen, "enter") == 1
def test_select_no_wrap_clamps_at_the_ends() -> None:
"""With wrap off, Up on the first row and Down on the last stay put (no cycling)."""
items = [Choice("alpha", 1), Choice("beta", 2), Choice("gamma", 3)]
screen = SelectScreen("pick", items, wrap=False)
screen.handle("up") # already on the first choice — must not jump to the last
assert _run(screen, "enter") == 1
screen = SelectScreen("pick", items, default=3, wrap=False) # last choice
screen.handle("down") # already on the last — must not wrap to the first
assert _run(screen, "enter") == 3
def test_select_pageup_pagedown_jump_by_a_page() -> None:
"""PageDown/PageUp move the highlight a page at a time, clamped to the choice range."""
from meshterm.ui.tui.select import _PAGE
items = [Choice(f"c{i}", i) for i in range(30)]
screen = SelectScreen("pick", items) # starts on the first choice
screen.handle("pagedown")
assert _run(screen, "enter") == _PAGE # advanced one page down
screen = SelectScreen("pick", items, default=25)
screen.handle("pageup")
assert _run(screen, "enter") == 25 - _PAGE # and one page back up
def _grouped_menu(default: object = None) -> SelectScreen:
"""A two-section menu long enough that each section scrolls past a small viewport."""
items: list = [Separator("── Channels ──")]
items += [Choice(f"chan{i}", ("c", i)) for i in range(6)]
items += [Separator("── Direct ──")]
items += [Choice(f"peer{i}", ("d", i)) for i in range(8)]
return SelectScreen("pick", items, default=default)
def _top_plain(screen: SelectScreen, viewport: int) -> str:
"""Slice the screen at its selection-driven scroll and return the top row's plain text."""
lines = screen.render_body(40)
visible, _above, _below = frame._visible_slice(screen, lines, viewport)
return Text.from_ansi(visible[0]).plain.strip()
def test_select_pins_section_heading_when_it_scrolls_off() -> None:
"""Selecting deep in a section keeps that section's heading pinned to the top row."""
# Highlighting a channel far enough down pushes the "Channels" heading off the top, so it
# is re-pinned rather than vanishing.
assert _top_plain(_grouped_menu(default=("c", 5)), viewport=6) == "── Channels ──"
# Deep into the Direct group, the pinned heading switches to that section's.
assert _top_plain(_grouped_menu(default=("d", 6)), viewport=6) == "── Direct ──"
def test_select_does_not_pin_a_heading_that_is_still_visible() -> None:
"""With the list scrolled to the top, the real heading shows — nothing is pinned over it."""
screen = _grouped_menu() # default selection is the first choice, so scroll stays at 0
lines = screen.render_body(40)
visible, above, _below = frame._visible_slice(screen, lines, 6)
assert Text.from_ansi(visible[0]).plain.strip() == "── Channels ──"
assert above is False # top of the list; no pinned duplicate and no "more above"
def test_select_pinned_heading_keeps_the_last_row_reachable() -> None:
"""Even with a heading pinned, the bottom choice stays fully visible (not clipped)."""
screen = _grouped_menu()
screen.handle("end") # highlight the final choice
lines = screen.render_body(40)
visible, _above, below = frame._visible_slice(screen, lines, 6)
assert Text.from_ansi(visible[0]).plain.strip() == "── Direct ──" # heading pinned
assert any("peer7" in Text.from_ansi(row).plain for row in visible) # last row shown
assert below is False # and we know we're at the bottom
# --- scroll ------------------------------------------------------------------