From bdbe5cce309397cdfdd5524a9cce2937e2d3a9aa Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Wed, 8 Jul 2026 20:30:06 -0400 Subject: [PATCH] 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 --- meshterm/ui/chat.py | 106 +++++++++++++++++++------- meshterm/ui/tui/frame.py | 7 +- meshterm/ui/tui/prompt.py | 32 ++++++++ meshterm/ui/tui/screen.py | 149 ++++++++++++++++++++++++++++--------- meshterm/ui/tui/select.py | 88 ++++++++++++++-------- meshterm/ui/tui/session.py | 6 ++ tests/test_chat.py | 111 +++++++++++++++++++++++++++ tests/test_tui.py | 110 +++++++++++++++++++++++++-- 8 files changed, 506 insertions(+), 103 deletions(-) diff --git a/meshterm/ui/chat.py b/meshterm/ui/chat.py index 4ce22f9..c339ac4 100644 --- a/meshterm/ui/chat.py +++ b/meshterm/ui/chat.py @@ -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 diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index 6cea9f1..4ca7edb 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -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 diff --git a/meshterm/ui/tui/prompt.py b/meshterm/ui/tui/prompt.py index f93a481..8f26d8d 100644 --- a/meshterm/ui/tui/prompt.py +++ b/meshterm/ui/tui/prompt.py @@ -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. diff --git a/meshterm/ui/tui/screen.py b/meshterm/ui/tui/screen.py index 1683bfb..bc4b313 100644 --- a/meshterm/ui/tui/screen.py +++ b/meshterm/ui/tui/screen.py @@ -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) diff --git a/meshterm/ui/tui/select.py b/meshterm/ui/tui/select.py index 6d7d73c..e30ef01 100644 --- a/meshterm/ui/tui/select.py +++ b/meshterm/ui/tui/select.py @@ -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) diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 550be8d..e6a6fc1 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -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", diff --git a/tests/test_chat.py b/tests/test_chat.py index 05e6988..ff65974 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -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. diff --git a/tests/test_tui.py b/tests/test_tui.py index 61bb335..5352a80 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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)