The Courier outbox shows each message and pans instead of cutting
Each row now carries a third column, MESSAGE, after TARGET and TRIGGER (or OUTCOME). Nothing is shortened to fit: the rows and their headers pan together with ←→, and the footer says so while the table runs past the edge. The outbox no longer filters as you type, which keeps the footer within 72 cells. A column header that pans now pins with its section heading, as a header that does not pan always did. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0b931e7cc4
commit
53ffb984cb
3 files changed
+149
-85
No files matched your search
@@ -4,16 +4,17 @@
|
||||
The interactive face of the ``courier`` tool. One select-list screen holds the whole
|
||||
feature (the persistent-backdrop pattern). It has these parts:
|
||||
|
||||
- The waiting outbox. Each row shows its target (the recipient) and its trigger: what the
|
||||
entry waits for before it goes out. It waits to hear the contact, it is scheduled for a
|
||||
time, or it backs off between retries.
|
||||
- The waiting outbox. Each row shows its target (the recipient), its trigger (what the
|
||||
entry waits for before it goes out), and its message. It waits to hear the contact, it
|
||||
is scheduled for a time, or it backs off between retries.
|
||||
- The finished history (delivered or given up), with the outcome of each entry.
|
||||
- The queueing flow: select a contact, write the message, choose when to send it.
|
||||
|
||||
A row does not show its message. Enter on a waiting entry opens the message to edit, and
|
||||
Enter on a finished entry opens it to read. ``^R`` on a waiting entry is *Send now* (one
|
||||
forced attempt, and the outcome is in a dialog). Delete removes an entry of either section,
|
||||
after a confirm.
|
||||
The entries are a table that ←→ pan as one unit, under the column headers. Nothing in a
|
||||
row is shortened: a long name or a long message is all there, and the pan brings it into
|
||||
view. Enter on a waiting entry opens the message to edit, and Enter on a finished entry
|
||||
opens it to read. ``^R`` on a waiting entry is *Send now* (one forced attempt, and the
|
||||
outcome is in a dialog). Delete removes an entry of either section, after a confirm.
|
||||
|
||||
The outbox is **live** while it is open. Each row is a callable title that the code
|
||||
computes again at each paint, so "retry in ~N m" counts down in real time. A ticker
|
||||
@@ -31,6 +32,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -48,7 +50,7 @@ from .menus import (
|
||||
run_steps,
|
||||
section_heading,
|
||||
)
|
||||
from .pathline import PathHop, cut_to
|
||||
from .pathline import PathHop
|
||||
from .tui import CANCEL, DM_BYTE_LIMIT, Choice, DeleteRequest, SelectScreen, Separator
|
||||
from .watchtower_screen import contact_watch_key
|
||||
from .widgets import (
|
||||
@@ -88,21 +90,17 @@ _COMMAND_ICONS = ("📨", "🗑")
|
||||
#: The seconds between the refresh ticks of the open outbox (shape check and paint).
|
||||
_REFRESH_S = 1.0
|
||||
|
||||
#: The cells between the TARGET lane and the lane after it.
|
||||
#: The cells between two lanes of the table.
|
||||
_GAP = 2
|
||||
|
||||
#: The fewest cells that the last lane (TRIGGER or OUTCOME) keeps on a narrow screen. A
|
||||
#: long name gives cells back first, because the first letters of a chip already show who
|
||||
#: it is, and the trigger is what the user opens the outbox to read.
|
||||
_LAST_MIN = 20
|
||||
|
||||
#: How many finished entries the history section shows. The store keeps more (refer to the
|
||||
#: ``courier_history_kept`` preference).
|
||||
_DONE_SHOWN = 15
|
||||
|
||||
#: The footer of the outbox. :meth:`CourierOutboxScreen._key_atoms` adds the atoms of the
|
||||
#: keys that act on an entry, only while the highlight is on such an entry.
|
||||
_OUTBOX_HINT = "↑↓ move · type to filter · Enter open · Esc back"
|
||||
#: keys that act on an entry, only while the highlight is on such an entry. The select
|
||||
#: list adds ``←→ scroll`` while the table is wider than the screen.
|
||||
_OUTBOX_HINT = "↑↓ move · Enter open · Esc back"
|
||||
_SEND_HINT = "^R send"
|
||||
_DELETE_HINT = "Del delete"
|
||||
|
||||
@@ -242,7 +240,11 @@ class CourierOutboxScreen(SelectScreen):
|
||||
Three keys act on the highlighted entry. Enter opens its message (refer to
|
||||
:func:`_open_entry`), Delete asks to remove it, and ``^R`` sends a waiting entry now.
|
||||
The footer and the F-key lane name ``^R`` and Delete only on the rows on which they act.
|
||||
``^R`` is a chord, because this list filters as the user types.
|
||||
``^R`` is the chord that the app uses for "send again" in each conversation.
|
||||
|
||||
The list does not filter as the user types. The footer of a table that pans must also
|
||||
name ←→, and with the filter atom too, the footer was wider than the 72 cells of a
|
||||
terminal. An outbox holds a few entries, so a filter has little to find in it.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: AppContext, *, default: Any = None) -> None:
|
||||
@@ -258,6 +260,7 @@ class CourierOutboxScreen(SelectScreen):
|
||||
_menu_items(ctx, ctx.courier_store.entries()),
|
||||
default=default,
|
||||
footer_hint=_OUTBOX_HINT,
|
||||
filterable=False,
|
||||
key_hint=self._key_atoms,
|
||||
)
|
||||
|
||||
@@ -356,13 +359,15 @@ def _menu_items(ctx: AppContext, entries: list[QueuedMessage]) -> list:
|
||||
"""Build the rows of the screen: the waiting outbox, then the finished history.
|
||||
|
||||
Each section is a table under its own column header: the status mark, the TARGET (the
|
||||
recipient), and one more lane. In the outbox, that lane is the TRIGGER: what the entry
|
||||
waits for before it goes out. In the history, it is the OUTCOME. A row does not show
|
||||
the message, because Enter opens it (refer to :func:`_open_entry`).
|
||||
recipient), a second lane, and the MESSAGE. In the outbox, the second lane is the
|
||||
TRIGGER: what the entry waits for before it goes out. In the history, it is the
|
||||
OUTCOME. The rows and the headers pan as one unit (:attr:`Choice.pans`), so each lane
|
||||
is at its full width and nothing is shortened. The two command rows do not pan.
|
||||
|
||||
The entry rows are callables of the render width. Thus their live state renders again
|
||||
at each paint, and their TARGET lane fits the screen (refer to :func:`_target_lane`).
|
||||
The fixed action rows stay plain strings.
|
||||
The entry rows are callables with no argument, so that their live state renders again
|
||||
at each paint. The second lane is as wide as its widest text at that paint (refer to
|
||||
``second_w`` below), in both sections. Thus MESSAGE starts in the same cell in each
|
||||
section, and it moves when a countdown changes its number of digits.
|
||||
"""
|
||||
waiting = [m for m in entries if m.status == QUEUED]
|
||||
done = [m for m in entries if m.status != QUEUED][:_DONE_SHOWN]
|
||||
@@ -379,19 +384,29 @@ def _menu_items(ctx: AppContext, entries: list[QueuedMessage]) -> list:
|
||||
# in each. It is never narrower than its label, so the label never runs into the next.
|
||||
target_w = max([cell_len("TARGET")] + [_target_chip(m).cell_len for m in waiting + done])
|
||||
|
||||
def second_w() -> int:
|
||||
"""The width of the second lane at this paint: its widest label, trigger, or outcome.
|
||||
|
||||
The rows hold the live records of the store, so a trigger that changes (a
|
||||
countdown, a contact that was heard) gives its new width at the next paint.
|
||||
"""
|
||||
cells = [cell_len("TRIGGER"), cell_len("OUTCOME")]
|
||||
cells += [_trigger(ctx, m).cell_len for m in waiting]
|
||||
cells += [cell_len(_outcome(m)) for m in done]
|
||||
return max(cells)
|
||||
|
||||
items: list = [section_heading("Outbox")]
|
||||
if waiting:
|
||||
items.append(_column_header("TRIGGER", lane, target_w))
|
||||
items.append(_column_header("TRIGGER", lane, target_w, second_w))
|
||||
else:
|
||||
items.append(Separator(" empty — queued messages wait here for their moment"))
|
||||
for message in waiting:
|
||||
items.append(
|
||||
Choice(
|
||||
lambda width, m=message: _waiting_row(
|
||||
ctx, m, lane, _target_lane(lane, target_w, width)
|
||||
),
|
||||
lambda m=message: _waiting_row(ctx, m, lane, target_w, second_w()),
|
||||
("msg", message.ident),
|
||||
deletable=True,
|
||||
pans=True,
|
||||
)
|
||||
)
|
||||
items.append(Separator(" ")) # put space between the action and the outbox rows above it
|
||||
@@ -399,15 +414,14 @@ def _menu_items(ctx: AppContext, entries: list[QueuedMessage]) -> list:
|
||||
|
||||
if done:
|
||||
items.append(section_heading("Finished"))
|
||||
items.append(_column_header("OUTCOME", lane, target_w))
|
||||
items.append(_column_header("OUTCOME", lane, target_w, second_w))
|
||||
for message in done:
|
||||
items.append(
|
||||
Choice(
|
||||
lambda width, m=message: _done_row(
|
||||
m, lane, _target_lane(lane, target_w, width)
|
||||
),
|
||||
lambda m=message: _done_row(m, lane, target_w, second_w()),
|
||||
("msg", message.ident),
|
||||
deletable=True,
|
||||
pans=True,
|
||||
)
|
||||
)
|
||||
items.append(Choice(marked_label("🗑", "Clear finished", "", lane=lane), _CLEAR))
|
||||
@@ -415,39 +429,31 @@ def _menu_items(ctx: AppContext, entries: list[QueuedMessage]) -> list:
|
||||
return items
|
||||
|
||||
|
||||
def _column_header(label: str, lane: int, target_w: int) -> Separator:
|
||||
"""The column header of one section: ``TARGET`` over the chips, then ``label``.
|
||||
def _column_header(label: str, lane: int, target_w: int, second_w: Callable[[], int]) -> Separator:
|
||||
"""The column header of one section: ``TARGET``, then ``label``, then ``MESSAGE``.
|
||||
|
||||
It comes immediately after the heading of its section. Thus it pins with the heading
|
||||
while the rows of the section scroll. It starts after the pointer, the mark, and the
|
||||
space after the mark, so that ``TARGET`` is over the first cell of each chip.
|
||||
space after the mark, so that ``TARGET`` is over the first cell of each chip. It pans
|
||||
with the rows, so each label stays over its lane.
|
||||
|
||||
Args:
|
||||
label: The name of the last lane: ``TRIGGER`` in the outbox, ``OUTCOME`` in the
|
||||
label: The name of the second lane: ``TRIGGER`` in the outbox, ``OUTCOME`` in the
|
||||
history.
|
||||
lane: The icon column of the list in cells (refer to :func:`_menu_items`).
|
||||
target_w: The width of the widest chip in cells, without the gap after it.
|
||||
second_w: Gives the width of the second lane at this paint, without the gap.
|
||||
"""
|
||||
|
||||
def header(width: int) -> str:
|
||||
# The rows get the width less the 2 cells of the pointer, so the header asks for
|
||||
# the TARGET lane at that width too. Thus each label stays over its lane.
|
||||
target = _target_lane(lane, target_w, width - 2)
|
||||
lanes = [Lane("TARGET", target + _GAP), Lane(label)]
|
||||
lanes = [
|
||||
Lane("TARGET", target_w + _GAP),
|
||||
Lane(label, second_w() + _GAP),
|
||||
Lane("MESSAGE"),
|
||||
]
|
||||
return column_header(lanes, width, indent=2 + lane + 1)
|
||||
|
||||
return Separator(header)
|
||||
|
||||
|
||||
def _target_lane(lane: int, target_w: int, avail: int) -> int:
|
||||
"""The width of the TARGET lane in a row that has ``avail`` cells after the pointer.
|
||||
|
||||
The lane is as wide as the widest chip. Where the last lane then gets fewer than
|
||||
:data:`_LAST_MIN` cells, the TARGET lane gives cells back, and a chip that is too wide
|
||||
breaks off on its crack. It is never narrower than its label.
|
||||
"""
|
||||
room = avail - (lane + 1) - _GAP - _LAST_MIN
|
||||
return max(cell_len("TARGET"), min(target_w, room))
|
||||
return Separator(header, pans=True)
|
||||
|
||||
|
||||
def _entry_mark(mark: str, style: str, lane: int) -> Text:
|
||||
@@ -485,13 +491,26 @@ def _target_chip(message: QueuedMessage) -> Text:
|
||||
return hop_chip(PathHop(message.node_name, style="muted"), role=DESTINATION)
|
||||
|
||||
|
||||
def _row(mark: Text, chip: Text, target_w: int, last: Text) -> Text:
|
||||
"""One entry row: the mark, the chip fitted to the TARGET lane, then the last lane."""
|
||||
if chip.cell_len > target_w:
|
||||
chip = cut_to(chip, target_w) # the chip continues past its lane, so it cracks
|
||||
def _row(
|
||||
mark: Text,
|
||||
chip: Text,
|
||||
target_w: int,
|
||||
second: Text,
|
||||
second_w: int,
|
||||
message: QueuedMessage,
|
||||
style: str,
|
||||
) -> Text:
|
||||
"""One entry row: the mark, the chip, the second lane, and the full message.
|
||||
|
||||
Each lane is padded to its width, and nothing is shortened. The pan of the list brings
|
||||
a part that is past the edge into view. A line break in the message becomes a space,
|
||||
because a row is one line.
|
||||
"""
|
||||
mark.append_text(chip)
|
||||
mark.append(" " * (max(0, target_w - chip.cell_len) + _GAP))
|
||||
mark.append_text(last)
|
||||
mark.append_text(second)
|
||||
mark.append(" " * (max(0, second_w - second.cell_len) + _GAP))
|
||||
mark.append(" ".join(message.text.splitlines()), style=style)
|
||||
return mark
|
||||
|
||||
|
||||
@@ -524,21 +543,34 @@ def _outcome(message: QueuedMessage) -> str:
|
||||
|
||||
|
||||
def _waiting_row(
|
||||
ctx: AppContext, message: QueuedMessage, lane: int = _MARK_LANE, target_w: int = 0
|
||||
ctx: AppContext,
|
||||
message: QueuedMessage,
|
||||
lane: int = _MARK_LANE,
|
||||
target_w: int = 0,
|
||||
second_w: int = 0,
|
||||
) -> Text:
|
||||
"""One waiting entry: the mark, the TARGET, and the TRIGGER."""
|
||||
return _row(
|
||||
_entry_mark("⏳", "warn", lane), _target_chip(message), target_w, _trigger(ctx, message)
|
||||
)
|
||||
"""One waiting entry: the mark, the TARGET, the TRIGGER, and the MESSAGE.
|
||||
|
||||
The message has the colour of plain text, because it is the content of the row.
|
||||
"""
|
||||
mark = _entry_mark("⏳", "warn", lane)
|
||||
trigger = _trigger(ctx, message)
|
||||
return _row(mark, _target_chip(message), target_w, trigger, second_w, message, "")
|
||||
|
||||
|
||||
def _done_row(message: QueuedMessage, lane: int = _MARK_LANE, target_w: int = 0) -> Text:
|
||||
"""One finished entry: the outcome mark, the TARGET, and the OUTCOME."""
|
||||
def _done_row(
|
||||
message: QueuedMessage, lane: int = _MARK_LANE, target_w: int = 0, second_w: int = 0
|
||||
) -> Text:
|
||||
"""One finished entry: the outcome mark, the TARGET, the OUTCOME, and the MESSAGE.
|
||||
|
||||
A finished entry is history, so each lane after the mark is muted.
|
||||
"""
|
||||
if message.status == DELIVERED:
|
||||
mark = _entry_mark("✓", "ok", lane)
|
||||
else:
|
||||
mark = _entry_mark("✗", "err", lane)
|
||||
return _row(mark, _target_chip(message), target_w, Text(_outcome(message), style="muted"))
|
||||
outcome = Text(_outcome(message), style="muted")
|
||||
return _row(mark, _target_chip(message), target_w, outcome, second_w, message, "muted")
|
||||
|
||||
|
||||
# --- the flows --------------------------------------------------------------------------
|
||||
|
||||
@@ -885,6 +885,10 @@ class SelectScreen(Screen):
|
||||
elif item.heading:
|
||||
block = list(drawn)
|
||||
self._sticky_headers.append((len(lines), block))
|
||||
elif block is not None:
|
||||
# The column header of a section, under its heading: it pins
|
||||
# with the heading, as the preamble below does.
|
||||
block.extend(drawn)
|
||||
elif item.pinned:
|
||||
# A pinned header is drawn outside the body slice and is exactly one
|
||||
# line tall. Thus it crops like a row and does not wrap. A column header
|
||||
|
||||
+49
-21
@@ -696,12 +696,14 @@ def _cell(line: str, word: str) -> int:
|
||||
return cell_len(line[: line.index(word)])
|
||||
|
||||
|
||||
def test_outbox_rows_are_target_and_trigger_under_column_headers(tmp_path: Path) -> None:
|
||||
"""Each section is a table: TARGET and TRIGGER in the outbox, TARGET and OUTCOME after it.
|
||||
def test_outbox_rows_are_target_trigger_and_message_under_column_headers(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Each section is a table: TARGET, TRIGGER, and MESSAGE, or TARGET, OUTCOME, and MESSAGE.
|
||||
|
||||
A row does not show its message, because Enter opens it. Each section has its own
|
||||
header, so the lane after TARGET has one meaning in each section. That lane starts in
|
||||
the same cell in both sections, and each label is over its lane.
|
||||
Each section has its own header, so the lane after TARGET has one meaning in each
|
||||
section. Each lane starts in the same cell in both sections, and each label is over its
|
||||
lane.
|
||||
"""
|
||||
from meshterm.ui.courier_screen import CourierOutboxScreen
|
||||
|
||||
@@ -711,7 +713,6 @@ def test_outbox_rows_are_target_and_trigger_under_column_headers(tmp_path: Path)
|
||||
ctx.courier_store.mark_delivered(landed.ident)
|
||||
lines = _outbox_plain(CourierOutboxScreen(ctx)).splitlines()
|
||||
|
||||
assert not any("hold this" in line or "got it" in line for line in lines)
|
||||
trigger = next(line for line in lines if "TRIGGER" in line)
|
||||
outcome = next(line for line in lines if "OUTCOME" in line)
|
||||
waiting = next(line for line in lines if "Waiting" in line)
|
||||
@@ -720,6 +721,8 @@ def test_outbox_rows_are_target_and_trigger_under_column_headers(tmp_path: Path)
|
||||
assert _cell(outcome, "TARGET") == _cell(trigger, "TARGET")
|
||||
assert _cell(trigger, "TRIGGER") == _cell(waiting, "when heard")
|
||||
assert _cell(outcome, "OUTCOME") == _cell(done, "delivered") == _cell(trigger, "TRIGGER")
|
||||
assert _cell(trigger, "MESSAGE") == _cell(waiting, "hold this text")
|
||||
assert _cell(outcome, "MESSAGE") == _cell(done, "got it") == _cell(trigger, "MESSAGE")
|
||||
|
||||
|
||||
def test_outbox_triggers_name_what_each_entry_waits_for(tmp_path: Path) -> None:
|
||||
@@ -758,24 +761,34 @@ def test_outbox_cut_ends_on_an_ellipsis_not_on_a_stray_crack(tmp_path: Path, pow
|
||||
assert row.endswith("…") and CRACK_TAIL not in row
|
||||
|
||||
|
||||
def test_a_long_target_gives_cells_to_the_trigger(tmp_path: Path, powerline) -> None:
|
||||
"""On a narrow screen, a long name breaks off on its crack, and the trigger stays readable.
|
||||
def test_the_outbox_pans_and_shortens_nothing(tmp_path: Path, powerline) -> None:
|
||||
"""A narrow screen shows the full name, and ←→ pan the table to the full message.
|
||||
|
||||
The TARGET lane is as wide as the widest chip only while the TRIGGER lane keeps its
|
||||
minimum. The header follows the same lane, so TRIGGER stays over the triggers.
|
||||
No lane gets shorter to fit. The header pans with the rows, so MESSAGE stays over the
|
||||
messages. The command rows do not pan, and the footer names ←→ while there is more.
|
||||
"""
|
||||
from meshterm.ui.courier_screen import _LAST_MIN, CourierOutboxScreen
|
||||
from meshterm.ui.courier_screen import CourierOutboxScreen
|
||||
from meshterm.ui.pathline import CRACK_TAIL
|
||||
|
||||
powerline(True)
|
||||
ctx = _outbox_ctx(tmp_path)
|
||||
ctx.courier_store.queue(NODE, "A Rather Long Contact Name For Width", "hello")
|
||||
lines = _outbox_plain(CourierOutboxScreen(ctx), 53).splitlines()
|
||||
header = next(line for line in lines if "TARGET" in line)
|
||||
name = "A Rather Long Contact Name For Width"
|
||||
ctx.courier_store.queue(NODE, name, "the whole message must be there to read")
|
||||
screen = CourierOutboxScreen(ctx)
|
||||
lines = _outbox_plain(screen, 53).splitlines()
|
||||
row = next(line for line in lines if "⏳" in line)
|
||||
assert row.rstrip().endswith("when heard")
|
||||
assert CRACK_TAIL in row # the chip continues past its lane
|
||||
assert _cell(header, "TRIGGER") == _cell(row, "when heard") <= 53 - _LAST_MIN
|
||||
assert name in row and CRACK_TAIL not in row
|
||||
assert row.endswith("…") # the row goes on past the edge
|
||||
assert "←→ scroll" in screen.footer_hint
|
||||
|
||||
for _ in range(20):
|
||||
screen.handle("right")
|
||||
lines = _outbox_plain(screen, 53).splitlines()
|
||||
header = next(line for line in lines if "MESSAGE" in line)
|
||||
row = next(line for line in lines if "message must" in line)
|
||||
assert row.endswith("the whole message must be there to read")
|
||||
assert _cell(header, "MESSAGE") == _cell(row, "the whole message")
|
||||
assert any(line.startswith(" 📨 Queue a message…") for line in lines)
|
||||
|
||||
|
||||
def test_outbox_keys_act_only_on_the_rows_they_name(tmp_path: Path) -> None:
|
||||
@@ -798,9 +811,7 @@ def test_outbox_keys_act_only_on_the_rows_they_name(tmp_path: Path) -> None:
|
||||
return screen
|
||||
|
||||
screen = on(("msg", waiting.ident))
|
||||
assert screen.footer_hint == (
|
||||
"↑↓ move · type to filter · Enter open · ^R send · Del delete · Esc back"
|
||||
)
|
||||
assert screen.footer_hint == ("↑↓ move · Enter open · ^R send · Del delete · Esc back")
|
||||
assert cell_len(screen.footer_hint) <= 72
|
||||
slot = screen.picocalc_lyra_lane[2]
|
||||
assert (slot.label, slot.opp_label, slot.enabled, slot.opp_enabled) == (
|
||||
@@ -821,7 +832,7 @@ def test_outbox_keys_act_only_on_the_rows_they_name(tmp_path: Path) -> None:
|
||||
assert resolved.pop() == DeleteRequest(("msg", landed.ident))
|
||||
|
||||
screen = on(_QUEUE)
|
||||
assert screen.footer_hint == "↑↓ move · type to filter · Enter open · Esc back"
|
||||
assert screen.footer_hint == "↑↓ move · Enter open · Esc back"
|
||||
screen.handle("delete")
|
||||
screen.handle("retry")
|
||||
assert resolved == []
|
||||
@@ -959,3 +970,20 @@ def test_recipient_picker_sort_keys_walk_the_ring(tmp_path: Path) -> None:
|
||||
assert sort.column == "name" and sort.ascending
|
||||
values = [c.value for c in screen._choices()]
|
||||
assert values == [a, z]
|
||||
|
||||
|
||||
def test_a_panned_column_header_pins_with_its_section_heading(tmp_path: Path) -> None:
|
||||
"""The header under a section heading pans, and it also pins with the heading.
|
||||
|
||||
A header that does not pan was already the preamble of its heading. The pan branch of
|
||||
the select list once left it out, so the labels scrolled away above their rows.
|
||||
"""
|
||||
from meshterm.ui.courier_screen import CourierOutboxScreen
|
||||
|
||||
ctx = _outbox_ctx(tmp_path)
|
||||
ctx.courier_store.queue(NODE, "Hub", "hello")
|
||||
screen = CourierOutboxScreen(ctx)
|
||||
screen.render_body(72)
|
||||
_line, block = screen._sticky_headers[0]
|
||||
plain = [re.sub(r"\x1b\[[0-9;]*m", "", line) for line in block]
|
||||
assert "Outbox" in plain[0] and "TARGET" in plain[1]
|
||||
Reference in new issue
Block a user