Navigate the TUI like a text editor

Make sticky headers a reusable Screen feature and lift paging and
section jumps onto the base Screen, so every scrollable surface shares
one implementation: the frame records each render's body height and
viewport, and the shared helpers page by a screenful, clamp to the
content, and walk between the boundaries a screen records in
_sticky_headers (a select list's group dividers, the chat transcript's
day dividers).

Remap keys to match a good editor:
- PageUp/PageDown scroll by a screenful rather than a fixed step.
- Ctrl+Home/End reach the top/bottom (the live tail in chat).
- Ctrl+PageUp/PageDown jump to the current/previous and next section.
- In chat, Home/End and Ctrl+Left/Right act on the compose line; word
  motion (start of current word, else previous/next) is added to the
  shared line editor, so every text prompt gains it too.

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:30:06 -04:00
1 parent 8906e4ed53
commit bdbe5cce30
8 files changed
+506 -103

No files matched your search

+81 -25
View File
@@ -33,10 +33,6 @@ from .tui.spinner import Spinner
if TYPE_CHECKING:
from ..context import AppContext
#: Fixed page step for PageUp/PageDown while scrolling the transcript (the screen isn't
#: told the viewport height, so a constant keeps paging predictable).
_PAGE = 10
#: How many past messages to load into the transcript when a conversation opens.
_HISTORY_LIMIT = 200
@@ -183,10 +179,10 @@ class ChatScreen(Screen):
"""Key hint, reflecting whether a message is picked for reply (channels only)."""
if not self._is_channel:
if any(m.outbound and m.acked is False for m in self._messages):
return "Enter send · Ctrl-R retry failed · ↑↓ scroll · Esc back"
return "Enter send · ↑↓/PgUp scroll · End latest · Esc back"
return "Enter send · Ctrl-R retry failed · ↑↓/PgUp scroll · ^End latest · Esc back"
return "Enter send · ↑↓/PgUp scroll · ^End latest · Esc back"
if self._selected is not None:
return "Enter reply (@mention) · ↑↓ pick · End/Esc cancel"
return "Enter reply (@mention) · ↑↓ pick · ^End/Esc cancel"
return "Enter send · ↑ pick a message to reply · Esc back"
# --- live updates --------------------------------------------------------
@@ -311,6 +307,10 @@ class ChatScreen(Screen):
view (channels only; direct threads never select).
"""
lines: list[str] = []
# Record each day divider as a sticky-header candidate, so the divider governing the
# topmost visible message is re-pinned to the top row once it scrolls off — the same
# base Screen.sticky_header the conversation picker uses for its section headings.
self._sticky_headers = []
prev_group: Optional[tuple[bool, str]] = None
prev_day = None
for idx, message in enumerate(self._messages):
@@ -324,9 +324,11 @@ class ChatScreen(Screen):
if new_day:
if lines:
lines += render_lines(Text(""), width)
lines += render_lines(
divider = render_lines(
Text(f"── {stamp:%a} {stamp:%b} {stamp.day} ──", style="muted"), width
)
self._sticky_headers.append((len(lines), divider[0]))
lines += divider
if new_day or group != prev_group:
if not new_day and lines:
lines += render_lines(Text(""), width) # gap between sender groups
@@ -458,7 +460,12 @@ class ChatScreen(Screen):
self._handle_flat(action, data)
def _handle_flat(self, action: str, data: str = "") -> None:
"""Direct-chat input: send on Enter, scroll the transcript, edit, or leave on Esc."""
"""Direct-chat input: send on Enter, scroll the transcript, edit, or leave on Esc.
Home/End and Ctrl+←/→ act on the compose line (like any text field); scrolling the
transcript is on the arrows, PageUp/PageDown (a screenful), Ctrl+Home/End (top / live
tail), and Ctrl+PageUp/PageDown (previous / next day divider).
"""
if action == "enter":
self._submit()
elif action == "retry":
@@ -466,24 +473,30 @@ class ChatScreen(Screen):
elif action == "escape":
self.resolve(CANCEL)
elif action == "up":
self._stick = False
self.scroll = max(0, self.scroll - 1)
self._detach_and(self.scroll_lines, -1)
elif action == "pageup":
self._stick = False
self.scroll = max(0, self.scroll - _PAGE)
self._detach_and(self.scroll_pages, -1)
elif action == "down":
self.scroll += 1
self._detach_and(self.scroll_lines, 1)
elif action == "pagedown":
self.scroll += _PAGE
elif action == "home":
self._stick = False
self.scroll = 0
elif action == "end":
self._stick = True
self._detach_and(self.scroll_pages, 1)
elif action == "ctrl_home":
self._detach_and(self.scroll_to_top)
elif action == "ctrl_pageup":
self._detach_and(self.scroll_to_section_start)
elif action == "ctrl_pagedown":
self._detach_and(self.scroll_to_next_section)
elif action == "ctrl_end":
self._stick = True # snap back to the live tail / compose line
else:
if self._editor.edit(action, data):
self._status = "" # trimming clears the "too long" notice
self._stick = True # typing snaps back to the live tail
self._stick = True # touching the compose line snaps back to the live tail
def _detach_and(self, move: Callable[..., None], *args: Any) -> None:
"""Detach from the live tail and run a scroll move (the shared scroll helpers)."""
self._stick = False
move(*args)
def _handle_channel(self, action: str, data: str = "") -> None:
"""Channel input: arrows pick a message to reply to; Enter sends or starts a reply.
@@ -507,16 +520,20 @@ class ChatScreen(Screen):
elif action == "up":
self._move_selection(-1)
elif action == "pageup":
self._move_selection(-_PAGE)
self._move_selection(-self._page_step)
elif action == "down":
self._move_selection(1)
elif action == "pagedown":
self._move_selection(_PAGE)
elif action == "home":
self._move_selection(self._page_step)
elif action == "ctrl_home":
if self._messages:
self._selected = 0
self._stick = False
elif action == "end":
elif action == "ctrl_pageup":
self._select_section(-1)
elif action == "ctrl_pagedown":
self._select_section(1)
elif action == "ctrl_end":
self._clear_selection()
self._stick = True # jump back to the live tail / compose line
else:
@@ -548,6 +565,45 @@ class ChatScreen(Screen):
self._selected = max(0, target)
self._stick = False
def _select_section(self, direction: int) -> None:
"""Jump the reply selection to the first message of the previous/next day.
The selection-space analogue of the transcript's Ctrl+PageUp/PageDown day jump: down
moves to the first message of the following day (dropping to the tail when there's no
later day); up moves to the first message of the current day, or the previous day's
when already atop one.
"""
if not self._messages:
return
starts = self._day_start_indices()
current = self._selected if self._selected is not None else len(self._messages) - 1
if direction > 0:
target = next((s for s in starts if s > current), None)
if target is None:
self._clear_selection()
self._stick = True
return
else:
governing = max((s for s in starts if s <= current), default=0)
target = (
governing
if governing < current
else max((s for s in starts if s < current), default=0)
)
self._selected = target
self._stick = False
def _day_start_indices(self) -> list[int]:
"""Message indices that begin a new local-day group — matching the transcript dividers."""
starts: list[int] = []
prev_day = None
for i, message in enumerate(self._messages):
day = message.created_at.astimezone().date()
if day != prev_day:
starts.append(i)
prev_day = day
return starts
def _clear_selection(self) -> None:
"""Drop any reply selection (focus returns to the compose line)."""
self._selected = None
+4 -3
View File
@@ -15,7 +15,7 @@ from rich.panel import Panel
from rich.text import Text
from .render import render_lines
from .screen import Screen, ScrollScreen
from .screen import Screen
def _visible_slice(screen: Screen, lines: list[str], viewport: int) -> tuple[list[str], bool, bool]:
@@ -37,8 +37,9 @@ def _visible_slice(screen: Screen, lines: list[str], viewport: int) -> tuple[lis
A tuple of (padded visible lines, more-above, more-below).
"""
total = len(lines)
if isinstance(screen, ScrollScreen):
screen.note_viewport(viewport)
# Record the body height and viewport so the screen's shared scroll helpers can page by a
# screenful and clamp to the content (see :meth:`Screen.note_metrics`).
screen.note_metrics(total, viewport)
cursor = screen.cursor_line()
scroll = screen.scroll
+32
View File
@@ -57,10 +57,42 @@ class _LineEditor:
self.cursor = 0
elif action == "end":
self.cursor = len(self.text)
elif action == "ctrl_left":
self.cursor = self._word_left(self.cursor)
elif action == "ctrl_right":
self.cursor = self._word_right(self.cursor)
else:
return False
return True
def _word_left(self, pos: int) -> int:
"""Index of the start of the word at/left of ``pos`` (the previous word if already there).
Skips any whitespace immediately left of the cursor, then the run of word characters, so
from mid-word it lands on that word's first character and from a word start it steps back
to the previous word — the usual Ctrl+Left behavior of a text editor.
"""
i = pos
while i > 0 and self.text[i - 1].isspace():
i -= 1
while i > 0 and not self.text[i - 1].isspace():
i -= 1
return i
def _word_right(self, pos: int) -> int:
"""Index of the start of the next word after ``pos`` (or the line end if none remains).
Skips the current run of word characters, then the whitespace after it, landing on the
first character of the following word — the usual Ctrl+Right behavior.
"""
n = len(self.text)
i = pos
while i < n and not self.text[i].isspace():
i += 1
while i < n and self.text[i].isspace():
i += 1
return i
def render(self, mask: bool = False, *, overflow_at: Optional[int] = None) -> Text:
"""Render the current line with a reverse-video cursor cell.
+112 -37
View File
@@ -56,6 +56,15 @@ class Screen:
"""Initialize scroll state and the (later-assigned) result future."""
self.scroll = 0
self.future: Optional[asyncio.Future] = None
# (body-line index, rendered ANSI line) for each header eligible to be pinned to the
# top row once it scrolls off. A screen that wants sticky headers rebuilds this list
# while rendering its body (see :meth:`sticky_header`); the default is no headers.
self._sticky_headers: list[tuple[int, str]] = []
# The last render's body height and viewport, recorded by the frame (:meth:`note_metrics`)
# so the shared scroll helpers can page by a screenful of the *current* terminal and
# clamp to the content without every caller threading the sizes through.
self._scroll_total = 1
self._scroll_viewport = 1
# --- rendering -----------------------------------------------------------
@@ -82,10 +91,26 @@ class Screen:
"""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.
has scrolled off — a select screen's ``Channels``/``Direct`` divider, or the chat
transcript's ``── Wed Jul 8 ──`` day divider. ``scroll`` is the offset the body is
about to be sliced at; the frame draws the returned line as the top row.
The shared rule: among the headers a screen recorded in :attr:`_sticky_headers` while
rendering, find the last one at or above ``scroll`` (the one *governing* the top visible
row) and pin it — unless it is itself the top visible row (nothing to duplicate) or there
is none above. A screen opts in simply by populating :attr:`_sticky_headers`; the empty
default means no pinning.
"""
return None
governing: Optional[str] = None
governing_at = -1
for idx, line in self._sticky_headers:
if idx <= scroll:
governing, governing_at = line, idx
else:
break # headers are recorded in body order; nothing past here can govern
if governing is None or governing_at == scroll:
return None # no header above, or it's already the top visible row
return governing
# --- input ---------------------------------------------------------------
@@ -94,9 +119,10 @@ class Screen:
Args:
action: One of ``up``, ``down``, ``pageup``, ``pagedown``, ``home``, ``end``,
``left``, ``right``, ``shift_up``, ``shift_down``, ``shift_left``,
``shift_right``, ``enter``, ``escape``, ``backspace``, ``delete``,
``space``, ``tab``, or ``text`` (with ``data`` set to the character).
``ctrl_home``, ``ctrl_end``, ``ctrl_pageup``, ``ctrl_pagedown``, ``left``,
``right``, ``ctrl_left``, ``ctrl_right``, ``shift_up``, ``shift_down``,
``shift_left``, ``shift_right``, ``enter``, ``escape``, ``backspace``,
``delete``, ``space``, ``tab``, or ``text`` (with ``data`` set to the character).
data: The typed character when ``action`` is ``text``.
"""
if action == "escape":
@@ -113,16 +139,72 @@ class Screen:
# --- scrolling helpers (shared) ------------------------------------------
def scroll_by(self, delta: int, total: int, viewport: int) -> None:
"""Adjust :attr:`scroll` by ``delta`` lines, clamped to the content.
def note_metrics(self, total: int, viewport: int) -> None:
"""Record the last render's body height and viewport (the frame calls this each paint).
Args:
delta: Lines to move (negative scrolls up).
total: Total body line count.
viewport: Visible body height in lines.
The shared scroll/section jumps below size themselves from these, so a PageDown moves
by a screenful of the current terminal rather than a fixed constant, and the top/bottom
clamps track the real content height without the caller passing the sizes in.
"""
self._scroll_total = max(1, total)
self._scroll_viewport = max(1, viewport)
def note_viewport(self, viewport: int) -> None:
"""Record just the viewport height (for callers that don't know the body total)."""
self._scroll_viewport = max(1, viewport)
@property
def _page_step(self) -> int:
"""Lines a PageUp/PageDown moves: a screenful, bar one row kept for continuity."""
return max(1, self._scroll_viewport - 1)
def scroll_by(self, delta: int, total: int, viewport: int) -> None:
"""Adjust :attr:`scroll` by ``delta`` lines, clamped to the given content bounds."""
self.scroll = _clamp_scroll(self.scroll + delta, total, viewport)
def scroll_lines(self, delta: int) -> None:
"""Scroll by ``delta`` lines, clamped to the last-recorded content bounds."""
self.scroll = _clamp_scroll(
self.scroll + delta, self._scroll_total, self._scroll_viewport
)
def scroll_pages(self, pages: int) -> None:
"""Scroll by ``pages`` screenfuls (negative scrolls up)."""
self.scroll_lines(pages * self._page_step)
def scroll_to_top(self) -> None:
"""Jump the view to the first line."""
self.scroll = 0
def scroll_to_bottom(self) -> None:
"""Jump the view to the last screenful."""
self.scroll = max(0, self._scroll_total - self._scroll_viewport)
def scroll_to_section_start(self) -> None:
"""Move to the top of the section holding the top visible line.
Sections are delimited by the recorded :attr:`_sticky_headers` (a select list's group
dividers, the chat transcript's day dividers). If that heading is already the top line,
move to the *previous* section instead — so repeated presses walk up section by section,
the way a text editor's paragraph jump does. Falls back to the very top with no sections.
"""
offsets = [idx for idx, _ in self._sticky_headers]
governing = max((o for o in offsets if o <= self.scroll), default=None)
if governing is None:
target = 0
elif governing < self.scroll:
target = governing # up to the top of the section we're inside
else:
target = max((o for o in offsets if o < self.scroll), default=0)
self.scroll = _clamp_scroll(target, self._scroll_total, self._scroll_viewport)
def scroll_to_next_section(self) -> None:
"""Move to the start of the next section below the top visible line (else the bottom)."""
offsets = [idx for idx, _ in self._sticky_headers]
nxt = min((o for o in offsets if o > self.scroll), default=None)
target = nxt if nxt is not None else self._scroll_total
self.scroll = _clamp_scroll(target, self._scroll_total, self._scroll_viewport)
def _clamp_scroll(scroll: int, total: int, viewport: int) -> int:
"""Clamp a scroll offset to the valid range for the content and viewport.
@@ -141,8 +223,10 @@ def _clamp_scroll(scroll: int, total: int, viewport: int) -> int:
class ScrollScreen(Screen):
"""A read-only screen that shows a Rich renderable in a scrollable viewport.
Used for tool result windows and any long list. Content taller than the viewport
scrolls with the arrow keys / PageUp / PageDown / Home / End; Esc dismisses it.
Used for tool result windows and any long list. Content taller than the viewport scrolls
with the arrows / PageUp / PageDown (a screenful) / Home / End (or Ctrl+Home / Ctrl+End);
Esc dismisses it. Result windows carry no sections, so Ctrl+PageUp/PageDown just reach the
top/bottom.
"""
def __init__(
@@ -166,40 +250,31 @@ class ScrollScreen(Screen):
self.title = title
self.footer_hint = footer_hint
self.floating = floating
# The last viewport height the session rendered with, so PageUp/PageDown and the
# End key can move by a full page without the session having to pass it in.
self._viewport = 1
self._total = 1
def render_body(self, width: int) -> list[str]:
"""Render the wrapped content to ANSI lines and remember the total count."""
lines = render_lines(self._renderable, width)
self._total = max(1, len(lines))
self._scroll_total = max(1, len(lines))
return lines
def note_viewport(self, viewport: int) -> None:
"""Record the viewport height the session is about to render with.
Args:
viewport: Visible body height in lines.
"""
self._viewport = max(1, viewport)
def handle(self, action: str, data: str = "") -> None:
"""Scroll the viewport or dismiss the screen."""
page = max(1, self._viewport - 1)
"""Scroll the viewport (by line, page, or to an edge) or dismiss the screen."""
if action == "up":
self.scroll_by(-1, self._total, self._viewport)
self.scroll_lines(-1)
elif action == "down":
self.scroll_by(1, self._total, self._viewport)
self.scroll_lines(1)
elif action == "pageup":
self.scroll_by(-page, self._total, self._viewport)
self.scroll_pages(-1)
elif action in ("pagedown", "space"):
self.scroll_by(page, self._total, self._viewport)
elif action == "home":
self.scroll = 0
elif action == "end":
self.scroll = max(0, self._total - self._viewport)
self.scroll_pages(1)
elif action in ("home", "ctrl_home"):
self.scroll_to_top()
elif action in ("end", "ctrl_end"):
self.scroll_to_bottom()
elif action == "ctrl_pageup":
self.scroll_to_section_start()
elif action == "ctrl_pagedown":
self.scroll_to_next_section()
elif action in ("escape", "enter"):
self.resolve(None)
+57 -31
View File
@@ -56,10 +56,6 @@ 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.
@@ -129,6 +125,50 @@ class SelectScreen(Screen):
rows = self._rows() if rows is None else rows
return [it for it in rows if isinstance(it, Choice)]
def _section_starts(self) -> list[int]:
"""Choice indices that begin a section — the first choice after each run of separators.
Drives Ctrl+PageUp/PageDown section jumps. Empty while filtering, since the filtered
list is flattened to bare matches with no group headings to jump between.
"""
if self._filter:
return []
starts: list[int] = []
idx = 0
fresh = True # the next choice opens a section (top of the list, or just past a heading)
for item in self._items:
if isinstance(item, Separator):
fresh = True
elif isinstance(item, Choice):
if fresh:
starts.append(idx)
fresh = False
idx += 1
return starts
def _jump_section(self, direction: int) -> None:
"""Move the highlight to the next section (``+1``) or the current/previous one (``-1``).
Mirrors the scroll-based section jump of read-only screens, but in choice space: down
lands on the first choice of the following section; up lands on the first choice of the
section we're in, or the previous section's when already at a section start.
"""
starts = self._section_starts()
if not starts:
return
if direction > 0:
nxt = next((s for s in starts if s > self._index), None)
if nxt is not None:
self._index = nxt
else:
at_or_before = [s for s in starts if s <= self._index]
if not at_or_before:
self._index = 0
elif at_or_before[-1] < self._index:
self._index = at_or_before[-1]
else:
self._index = at_or_before[-2] if len(at_or_before) >= 2 else 0
# --- rendering -----------------------------------------------------------
def render_body(self, width: int) -> list[str]:
@@ -139,15 +179,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]] = []
# Record each section heading as a sticky-header candidate, so one that scrolls off is
# re-pinned to the top row by the base Screen.sticky_header. Filtering flattens the list
# to bare choices (see _rows), so this stays empty then and nothing is pinned.
self._sticky_headers = []
if self._filter:
lines.append(render_to_ansi(Text(f"/{self._filter}", style="warn"), width))
for item in rows:
if isinstance(item, Separator):
sep = render_to_ansi(Text(item.title, style="muted"), width)
self._separator_lines.append((len(lines), sep))
self._sticky_headers.append((len(lines), sep))
lines.append(sep)
continue
is_sel = item is selected
@@ -174,25 +215,6 @@ 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:
@@ -209,13 +231,17 @@ class SelectScreen(Screen):
len(choices) - 1, self._index + 1
)
elif action == "pageup":
self._index = max(0, self._index - _PAGE)
self._index = max(0, self._index - self._page_step)
elif action == "pagedown":
self._index = min(len(choices) - 1, self._index + _PAGE) if choices else 0
elif action == "home":
self._index = min(len(choices) - 1, self._index + self._page_step) if choices else 0
elif action in ("home", "ctrl_home"):
self._index = 0
elif action == "end":
elif action in ("end", "ctrl_end"):
self._index = max(0, len(choices) - 1)
elif action == "ctrl_pagedown":
self._jump_section(1)
elif action == "ctrl_pageup":
self._jump_section(-1)
elif action == "enter":
if choices:
self.resolve(choices[self._index].value)
+6
View File
@@ -44,6 +44,12 @@ _KEY_ACTIONS: dict[Any, str] = {
Keys.PageDown: "pagedown",
Keys.Home: "home",
Keys.End: "end",
Keys.ControlHome: "ctrl_home",
Keys.ControlEnd: "ctrl_end",
Keys.ControlPageUp: "ctrl_pageup",
Keys.ControlPageDown: "ctrl_pagedown",
Keys.ControlLeft: "ctrl_left",
Keys.ControlRight: "ctrl_right",
Keys.Enter: "enter",
Keys.Escape: "escape",
Keys.Backspace: "backspace",
+111
View File
@@ -789,6 +789,117 @@ def test_channel_transcript_groups_by_sender() -> None:
assert stamps[0] != stamps[1] # grouped Alice messages show distinct times
def _two_day_messages():
"""Two days of direct messages, long enough to overflow a small viewport."""
from datetime import datetime, timedelta, timezone
base = datetime(2026, 7, 5, 9, 0, tzinfo=timezone.utc)
day1 = [ChatMessage(text=f"day1-{i}", peer="d4e5f6a7", created_at=base + timedelta(minutes=i))
for i in range(4)]
day2 = [ChatMessage(text=f"day2-{i}", peer="d4e5f6a7",
created_at=base + timedelta(days=1, minutes=i)) for i in range(4)]
return day1 + day2
def test_chat_sticky_header_pins_the_governing_day_divider() -> None:
"""The day divider above the top row pins there once it scrolls off (like the picker)."""
screen = _screen(_StubSession(), send=None, messages=_two_day_messages())
screen.render_body(60)
(idx0, div0), (idx1, div1) = screen._sticky_headers
assert screen.sticky_header(0) is None # first divider is itself the top row
assert screen.sticky_header(idx1 - 1) == div0 # still within day one — its divider pins
assert screen.sticky_header(idx1) is None # day two's divider is now the top row
assert screen.sticky_header(idx1 + 1) == div1 # scrolled past it — day two's pins
def test_chat_frame_pins_a_day_divider_when_stuck_to_the_newest() -> None:
"""Rendered through the frame at the tail, a day divider occupies the pinned top row."""
from meshterm.ui.tui import frame
screen = _screen(_StubSession(), send=None, messages=_two_day_messages())
lines = screen.render_body(60) # sticks to the newest message, scrolling early days off
visible, above, _below = frame._visible_slice(screen, lines, 6)
top = _strip_ansi(visible[0]).strip()
assert top.startswith("──") and "Jul" in top # a day divider is pinned to the top row
assert above is True # and the frame flags there's more above the pin
def test_chat_home_end_and_word_keys_move_the_compose_cursor() -> None:
"""In a chat, Home/End and Ctrl+←/→ act on the compose line, not the transcript scroll."""
screen = _screen(_StubSession(), send=None, messages=_two_day_messages())
for ch in "hello world":
screen.handle("text", ch)
assert screen._editor.cursor == 11
screen.handle("home")
assert screen._editor.cursor == 0 # line start, not scroll-to-top
screen.handle("end")
assert screen._editor.cursor == 11
screen.handle("ctrl_left")
assert screen._editor.cursor == 6 # start of "world"
def test_chat_scroll_keys_detach_and_reattach_to_the_tail() -> None:
"""PageUp/Ctrl+Home detach from the live tail; Ctrl+End snaps back to it."""
screen = _screen(_StubSession(), send=None, messages=_two_day_messages())
lines = screen.render_body(60)
screen.note_metrics(total=len(lines), viewport=5)
screen.handle("pageup")
assert screen._stick is False # a screenful up detaches from the tail
screen.handle("ctrl_home")
assert screen.scroll == 0 and screen._stick is False
screen.handle("ctrl_end")
assert screen._stick is True # re-attached to the newest message
def test_chat_ctrl_page_scrolls_between_day_dividers() -> None:
"""Ctrl+PageDown/PageUp move the transcript scroll between day dividers."""
screen = _screen(_StubSession(), send=None, messages=_two_day_messages())
lines = screen.render_body(60)
screen.note_metrics(total=len(lines), viewport=5)
(idx0, _), (idx1, _) = screen._sticky_headers
screen.scroll = 0
screen.handle("ctrl_pagedown")
assert screen.scroll == idx1 and screen._stick is False # to the second day's divider
screen.handle("ctrl_pageup")
assert screen.scroll == idx0 # back to the first day's divider
def _two_day_channel_messages():
"""Channel messages spanning two local days (one sender), for section-jump tests."""
from datetime import datetime, timedelta, timezone
base = datetime(2026, 7, 5, 9, 0, tzinfo=timezone.utc)
day1 = [ChatMessage(text=f"Alice: d1-{i}", is_channel=True, channel_idx=0,
created_at=base + timedelta(minutes=i)) for i in range(3)]
day2 = [ChatMessage(text=f"Alice: d2-{i}", is_channel=True, channel_idx=0,
created_at=base + timedelta(days=1, minutes=i)) for i in range(3)]
return day1 + day2
def test_chat_channel_ctrl_page_selects_across_days() -> None:
"""In a channel, Ctrl+PageDown/PageUp move the reply selection to day boundaries."""
screen = _channel_screen(_two_day_channel_messages())
starts = screen._day_start_indices()
assert starts == [0, 3]
screen.handle("ctrl_home")
assert screen._selected == 0
screen.handle("ctrl_pagedown")
assert screen._selected == starts[1] # first message of the second day
screen.handle("ctrl_pageup")
assert screen._selected == starts[0] # back to the first day
def test_chat_channel_home_moves_the_compose_cursor() -> None:
"""A channel's Home key edits the compose line rather than jumping the selection."""
screen = _channel_screen(_two_day_channel_messages())
for ch in "reply":
screen.handle("text", ch)
assert screen._editor.cursor == 5
screen.handle("home")
assert screen._editor.cursor == 0
assert screen._selected is None # touching the compose line clears any reply selection
def test_channel_self_style_keyed_on_concept_not_label() -> None:
"""Our white 'self' style follows the message being outbound, not the 'you' label.
+103 -7
View File
@@ -19,7 +19,7 @@ from meshterm.ui.tui import frame
from meshterm.ui.tui.progress import ProgressScreen
from meshterm.ui.tui.prompt import AutocompleteScreen, ConfirmScreen, TextScreen
from meshterm.ui.tui.render import render_lines, render_to_ansi
from meshterm.ui.tui.screen import CANCEL, ScrollScreen
from meshterm.ui.tui.screen import CANCEL, Screen, ScrollScreen
from meshterm.ui.tui.select import Choice, SelectScreen, Separator
from meshterm.ui.tui.session import TuiSession
@@ -153,17 +153,17 @@ def test_select_no_wrap_clamps_at_the_ends() -> None:
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
def test_select_pageup_pagedown_jump_by_a_screenful() -> None:
"""PageDown/PageUp move the highlight a screenful at a time, clamped to the choice range."""
items = [Choice(f"c{i}", i) for i in range(30)]
screen = SelectScreen("pick", items) # starts on the first choice
screen.note_metrics(total=30, viewport=11) # a screenful is viewport - 1 = 10 rows
screen.handle("pagedown")
assert _run(screen, "enter") == _PAGE # advanced one page down
assert _run(screen, "enter") == 10 # advanced one page (viewport - 1) down
screen = SelectScreen("pick", items, default=25)
screen.note_metrics(total=30, viewport=11)
screen.handle("pageup")
assert _run(screen, "enter") == 25 - _PAGE # and one page back up
assert _run(screen, "enter") == 25 - 10 # and one page back up
def _grouped_menu(default: object = None) -> SelectScreen:
@@ -211,9 +211,87 @@ def test_select_pinned_heading_keeps_the_last_row_reachable() -> None:
assert below is False # and we know we're at the bottom
def test_screen_sticky_header_picks_the_governing_recorded_header() -> None:
"""The base Screen.sticky_header logic is generic over any recorded headers list.
Both the select list and the chat transcript reuse it by populating ``_sticky_headers``;
this exercises the shared rule directly: pin the last header at or above the offset, unless
it *is* the top row or none sits above it.
"""
screen = Screen()
screen._sticky_headers = [(0, "A"), (5, "B"), (12, "C")]
assert screen.sticky_header(0) is None # header A is itself the top row
assert screen.sticky_header(3) == "A" # scrolled past A, before B → A governs
assert screen.sticky_header(5) is None # header B is now the top row
assert screen.sticky_header(20) == "C" # below every header → the last one pins
assert Screen().sticky_header(9) is None # no recorded headers → nothing to pin
def test_select_ctrl_page_jumps_between_sections() -> None:
"""Ctrl+PageDown lands on the next section's first choice; Ctrl+PageUp walks back up."""
screen = _grouped_menu() # Channels (6) then Direct (8), highlight on the first choice
screen.handle("ctrl_pagedown")
assert _run(screen, "enter") == ("d", 0) # jumped to the first Direct choice
screen.handle("ctrl_pageup")
assert _run(screen, "enter") == ("c", 0) # already atop Direct → back to Channels' first
screen.handle("ctrl_pagedown")
assert _run(screen, "enter") == ("d", 0) # and forward to Direct again
# --- scroll ------------------------------------------------------------------
def test_screen_scroll_helpers_page_and_clamp_to_metrics() -> None:
"""The shared scroll helpers page by a screenful and clamp to the recorded body/viewport."""
screen = Screen()
screen.note_metrics(total=100, viewport=10)
screen.scroll_pages(1)
assert screen.scroll == 9 # a page is viewport - 1
screen.scroll_to_bottom()
assert screen.scroll == 90 # total - viewport
screen.scroll_lines(50)
assert screen.scroll == 90 # clamped, never past the bottom
screen.scroll_to_top()
assert screen.scroll == 0
def test_screen_section_scroll_walks_recorded_headers() -> None:
"""Ctrl+PageUp/PageDown move the scroll offset between recorded section boundaries."""
screen = Screen()
screen.note_metrics(total=100, viewport=10)
screen._sticky_headers = [(0, "A"), (20, "B"), (60, "C")]
screen.scroll_to_next_section()
assert screen.scroll == 20 # from the top → start of section B
screen.scroll_to_next_section()
assert screen.scroll == 60 # → start of C
screen.scroll_to_next_section()
assert screen.scroll == 90 # no section past C → clamp to the bottom
screen.scroll = 40 # mid-section B
screen.scroll_to_section_start()
assert screen.scroll == 20 # up to B's start
screen.scroll_to_section_start()
assert screen.scroll == 0 # already atop B → previous section (A at the top)
def test_scroll_screen_ctrl_edges_and_sectionless_fallback() -> None:
"""Ctrl+Home/End reach the edges; with no sections Ctrl+PageUp/PageDown do too."""
body = Text("\n".join(f"line {i}" for i in range(100)))
screen = ScrollScreen(body, title="log")
screen.render_body(40) # sets total = 100
screen.note_viewport(10)
screen.handle("ctrl_end")
assert screen.scroll == 90
screen.handle("ctrl_home")
assert screen.scroll == 0
screen.handle("ctrl_pagedown") # no sections recorded → falls through to the bottom
assert screen.scroll == 90
screen.handle("ctrl_pageup")
assert screen.scroll == 0
# --- scroll (existing) -------------------------------------------------------
def test_scroll_screen_paging_and_clamp() -> None:
"""PageDown advances by a page, End jumps to the bottom, both clamped to content."""
body = Text("\n".join(f"line {i}" for i in range(100)))
@@ -308,6 +386,24 @@ def test_text_screen_edits_and_validates() -> None:
assert _run(screen, "enter") == "ok"
def test_line_editor_word_motion() -> None:
"""Ctrl+Left/Right hop by word — to the current word's start, else the previous/next."""
from meshterm.ui.tui.prompt import _LineEditor
editor = _LineEditor("the quick brown fox") # cursor at the end (len 20)
editor.edit("ctrl_left")
assert editor.cursor == 17 # start of "fox"
editor.edit("ctrl_left")
assert editor.cursor == 11 # skips the double space → start of "brown"
editor.edit("ctrl_left")
assert editor.cursor == 4 # start of "quick"
editor.edit("ctrl_right")
assert editor.cursor == 11 # forward over "quick" and the spaces → start of "brown"
editor.cursor = 13 # mid-"brown"
editor.edit("ctrl_left")
assert editor.cursor == 11 # to the current word's start, not the previous word
def test_text_screen_password_masks() -> None:
"""A password field renders bullets, not the typed characters."""
screen = TextScreen("pw?", password=True)