From 82134607201340c4f549178d2e3891585eeb4029 Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Tue, 27 Oct 2026 22:36:08 +0000 Subject: [PATCH 1/5] feat(tui): read unread from the server's rows, retire seed_first_sight Read-state-sync phase 5c. The TUI shows the per-topic `unread` rows from GET /api/v1/groups/{gid}/read (unread_count, has_unread_mention) instead of the local "activity later than the mark" rule. ReadMarks becomes a cache of the server's answer; read_state.advanced and the poll fallback still update it. seed_first_sight, the open-topic exception and the per-topic mention fetch are removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Lk5Upqk2oCRiV3ZENbLyLL --- CHANGELOG.md | 26 ++++ README.md | 20 +-- planning/terminal-ui.md | 43 ++++--- src/pepper_cli/api_client.py | 52 +++++++- src/pepper_cli/tui/app.py | 91 ++++---------- src/pepper_cli/tui/poller.py | 26 ++-- src/pepper_cli/tui/state.py | 217 +++++++++++--------------------- tests/test_api_client_groups.py | 40 ++++++ tests/test_api_contract.py | 5 +- tests/test_tui_app.py | 3 +- tests/test_tui_mentions.py | 172 +++---------------------- tests/test_tui_poller.py | 11 ++ tests/test_tui_state.py | 217 ++++++++++---------------------- tests/test_tui_state_parity.py | 5 +- tests/test_tui_unread.py | 175 ++++++++++++++++++++++++++ tests/tui_support.py | 39 +++++- 16 files changed, 575 insertions(+), 567 deletions(-) create mode 100644 tests/test_tui_unread.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d994b8f..c7a7cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ All notable changes to `pepper` (distribution `vycari-pepper`). Newest first. Th [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [SemVer](https://semver.org/) (while 0.x, a minor bump may change flags or output). +## Unreleased + +The terminal UI no longer decides what is unread: it shows the server's `unread` rows +(read-state-sync phase 5c, tracked by Vycari/groups#274). + +Upgrade notes: + +- A fresh profile now matches the web: unread markers are what the server counts, with your own + messages and anything from before you joined left out. Before, the first poll marked every topic + read as of its last activity. +- A server without `unread` rows on `GET /api/v1/groups/{gid}/read` (older than phase 5b) shows no + unread markers at all, where the local rule used to light them. + +### Changed + +- Unread dots and `@` mention markers come from the server's `unread` rows (`unread_count`, + `has_unread_mention`). The mention marker no longer reads a topic's newest page of messages to look + for you. The local read marks are a cache of the server's answer: `read_state.advanced` and the + poll fallback still update them, and a mark that passes a topic's newest activity clears its row + until the server's next answer (#145). + +### Removed + +- `ReadMarks.seed_first_sight`, the local "activity later than the mark" unread rule, the + open-topic exception that went with it, and the per-topic mention fetch (`Mentions`) (#145). + ## 0.6.0 — 2026-10-26 The CLI now declares how it renders replies, and the terminal UI follows the person event stream so diff --git a/README.md b/README.md index 4b74161..fcc6eb7 100644 --- a/README.md +++ b/README.md @@ -421,9 +421,11 @@ or `no`). `pepper ` and every other command are unchanged: exit codes a non-interactive run do not depend on any of this. A full-screen view of your messages: Pepper first, then your rooms by recency with their topics -nested under the open room. Unread dots come from the server's read marks, and a topic you open -is marked read once its newest message has been on screen: with the thread scrolled up nothing -is marked, and a topic you read on another device clears here at once. +nested under the open room. Unread dots come from the server's own `unread` answer (the same one +the web shows, with your own messages and anything from before you joined already left out), so +a fresh install matches the web. A topic you open is marked read once its newest message has +been on screen: with the thread scrolled up nothing is marked, and a topic you read on another +device clears here at once. The composer at the bottom posts to whatever is open: @@ -454,12 +456,12 @@ The composer at the bottom posts to whatever is open: fold, an "Inactive" fold for rooms quiet for a week, and a "New topic" row. - Each conversation keeps its own draft while you move around. - **Mentions**: a topic (or a collapsed room) with unread activity that mentions you shows `@` - in place of the dot, and the terminal title reads `@ Pepper`. The room list carries no mention - flag, so when a topic turns unread the TUI reads its newest page of messages once (one request - per new activity, one at a time) and looks for your id. A topic with more unread messages than - one page can miss an older mention. The open topic needs none of this: a message that mentions - you shows in the accent colour. Set `PEPPER_TUI_BELL=1` to also ring the terminal bell when a - mention is first found in another topic; it is off by default. + in place of the dot, and the terminal title reads `@ Pepper`. The server says which topics + have an unread mention, so the TUI reads no messages to find one. In the open topic a message + that mentions you shows in the accent colour. Set `PEPPER_TUI_BELL=1` to also ring the + terminal bell when the server first reports an unread mention in another topic; it is off by + default. A server that does not send unread rows (older than read-state-sync phase 5b) shows + no unread markers. `pepper tui --root PATH` (repeatable; `--tools-root` is an alias) lets Pepper read files under `PATH` during chat turns, exactly as `pepper chat --root` does: read-only, enforced on this diff --git a/planning/terminal-ui.md b/planning/terminal-ui.md index 17b93cd..9ebf81a 100644 --- a/planning/terminal-ui.md +++ b/planning/terminal-ui.md @@ -57,7 +57,7 @@ scope, so the Messages section described here is stable. | Exactly one Messages row has the accent bar | `_sidebar.html` | Tree cursor highlight. The open conversation also gets a `▌` marker | | Header shows the room name and topic name, with an "archived" pill and "Room info" | `web/templates/_group_header.html` | Header line `Room › Topic`, with `[archived]` where it applies. Room info is P3 | | Unread is a **dot, not a count** ("a number would claim a precision…") | `web/static/unread_badge.js`, `web/static/groups_unread.js` | `●`. Never a number. The terminal title gets a `●` prefix while anything is unread | -| Read marks: the server is the store of record. `GET /groups/{gid}/read` seeds them and `PUT …/topics/{tid}/read` publishes. An open topic is always read. Your own sends never badge | `web/static/groups_read_marks.js` | Same rules, same endpoints (§6.3) | +| Read marks: the server is the store of record. `GET /groups/{gid}/read` seeds them and `PUT …/topics/{tid}/read` publishes. An open topic is always read. Your own sends never badge | `web/static/groups_read_marks.js` | Same endpoints (§6.3). Unread itself is the server's `unread` rows (read-state-sync phase 5c) | | Room poll is adaptive: 3s while active (interaction within 60s), 10s cooling (to 120s), 15s idle, paused while hidden. The rail polls every 15s with an ETag | `web/static/group_poll_cadence.js` (mirrors iOS `GroupLogic.roomPollInterval`) | Same cadence. "Hidden" maps to the terminal losing focus, where Textual reports it, and to the screen being suspended | | Poll cursor = newest `created_at` the server returned, minus a 10s lookback. Rows are deduped by id and placed in `(created_at, id)` order | `web/static/group_room.js` (pepper#4829) | Same rule, in `poller.py` (§6.2) | | One composer. Enter sends; Shift+Enter or Alt+Enter adds a newline. An `@` menu comes from the roster (none in chat). Quote-reply works in rooms only, and Esc cancels it. A slash menu (apps, commands, skills, topics). Drafts are kept per destination. The placeholder names the room | `web/static/composer.js` | Enter sends. **Alt+Enter or Ctrl+J** adds a newline, because most terminals can't tell Shift+Enter from Enter. Mentions, quote-reply, drafts per destination and the slash menu are covered in §5 and §13 | @@ -140,8 +140,9 @@ needed. The spike in §8 checked this pattern. 2. `GET /api/v1/groups` (`list_groups`). If `enabled=false`, the sidebar is **Pepper only**, with no rooms section and no error. If `degraded=true`, a one-line notice says rooms may be stale. Keep the ETag. -3. For each room, `GET /groups/{gid}/read` → read marks. This is one call per room, the same as the - web on first sight. It runs in parallel in a small pool and is cached for the session. +3. For each room, `GET /groups/{gid}/read` → read marks and the room's `unread` rows + (`{topic_id, unread_count, has_unread_mention, floor}`). This is one call per room, in parallel + in a small pool, cached for the session and refetched only for rooms whose topics moved. 4. `GET /groups/{gid}` for the **open** room only, to get `members[]`: `is_you` (the viewer's `person_id`, used for mentions) and the roster for `@`. 5. Open the Pepper DM by default. Optionally, reopen the last conversation (its id is the only @@ -156,7 +157,9 @@ them: - `read_state.advanced` (`{topic_id, group_id, last_read_at}`) is merged into the read marks directly, forward only, comparing at the server's full (microsecond) precision - (`ReadMarks.merge_advanced`). The sidebar repaints from the marks; no rail request is made. + (`ReadMarks.merge_advanced`). A mark that has passed the topic's newest activity clears its + cached unread row (`ReadMarks.settle`) and the sidebar repaints; no rail request is made. A + mark short of it (a partial read) leaves the server's count in place and asks the rail again. - A message, mention, member, room or app event fetches the rail now (a 304 when nothing changed), and the open topic too when the event names it. `chat.message` fetches the Pepper DM. - An edit, delete or preview event for a row of the open topic that is already on screen reads @@ -188,17 +191,23 @@ The cadence below is what runs with no stream. ### 6.3 Unread and read marks -There is one pure function in `tui/state.py`, ported from `groups_unread.js`/`groups_read_marks.js`: +The server decides what is unread (read-state-sync §6, phase 5): messages newer than the later of +the person's cursor and their floor, not their own, not tombstones, through the context rule. +`ReadMarks` in `tui/state.py` is the cache of that answer: -- A topic is unread when its `last_activity_at` is later than `read_through`, it is not the open - topic, and the latest activity is not the viewer's own pending send. +- A topic is unread when the server's `unread` row for it has `unread_count > 0`. A topic with no + row (an older server, a room whose read failed) is not unread: unknown is not unread. The + client never raises a count. - A room is unread when any of its non-archived topics is. -- **First sight**: a topic with no server mark is seeded as read at its current `last_activity_at`, - so a new install does not light up every room. The web does the same. +- **No first-sight seed.** A fresh profile shows exactly what the server says; nothing is written + back (first sight never PUTs). +- The one local derivation can only clear: a cursor (our own mark, or `read_state.advanced`) at or + after a topic's newest activity leaves nothing unread under any rule, so the cached row is + dropped until the server's next answer. A cursor short of it clears nothing. - **Read = the newest row has been on screen** (read-state-sync §5.2): with the terminal focused and the thread at its end, the newest row's `created_at` is published as `PUT …/topics/{tid}/read` (the server's own string, never a device clock). **Scrolled up, nothing is marked**, and the open - topic shows as unread while rows sit below the screen; reaching the end marks the newest row. Your + topic stays unread (the server's count) while rows sit below the screen; reaching the end marks the newest row. Your own send puts the thread back at the end and counts as read once the server stores it, if the thread is still there: a send that lands after you scrolled up, blurred the terminal or left the topic marks nothing (rows stored before it have not been on screen; groups does not advance @@ -211,9 +220,8 @@ There is one pure function in `tui/state.py`, ported from `groups_unread.js`/`gr **Mentions.** A message mentions you when `message.mentions` contains your `person_id`. In the **open** topic this is free, and the row shows in the accent colour with an `@` marker. For other -topics, `/api/v1/groups` gives only `last_activity_at`, and `/groups/activity` gives an excerpt but -no mention flag. So a mention dot elsewhere would cost one message fetch per newly-unread topic. The -web has no mention badge at all. §15 C. +topics the server's unread row carries `has_unread_mention`, so a mention marker costs no message +fetch (the per-topic fetch of P4 retired with read-state-sync phase 5c). §15 C. ### 6.4 Posting to a room (P2) @@ -313,7 +321,7 @@ src/pepper_cli/tui/ thread.py message list widget, pinning, selection, "N new" marker composer.py TextArea subclass: Enter/Alt+Enter, @ and / menus, quote, per-destination drafts poller.py workers: rail poll, topic poll (cursor + lookback + reconcile), DM history poll, backoff - state.py pure: ordering, unread rule, first-sight seed, cadence, cursor merge, draft keys + state.py pure: ordering, the server-unread cache, cadence, cursor merge, draft keys rows.py GroupMessage / ChatHistoryEntry -> Rich renderables (shares helpers with render.py) styles.tcss ``` @@ -347,8 +355,8 @@ the terminal and printing the traceback hint to stderr. ## 11. Testing -- **`state.py`**: plain unit tests covering ordering, the unread rule (first sight, open = read, own - pending), cursor and lookback merging, cadence transitions, and draft keys. These carry most of +- **`state.py`**: plain unit tests covering ordering, the server-unread cache (the server's rows, a + cursor clearing a row, a stale answer), cursor and lookback merging, cadence transitions, and draft keys. These carry most of the coverage. - **Pollers**: pytest-httpx, as the rest of the suite does. Cover the ETag 304 path, the 403 `enabled=false` path, backoff, a 401 stop, and the pepper#4829 race (an own echo, then an @@ -431,7 +439,8 @@ CLI release, released users see the TUI default arrive with the TUI itself. `PEP - (a) Web parity: a highlighted row in the open topic, and nothing extra elsewhere. - (b) As in (a), plus `@` in place of `●` on a sidebar row whose unread activity mentions you. This - costs one message fetch per newly-unread topic. + costs one message fetch per newly-unread topic (P4; since read-state-sync phase 5c the + server's `has_unread_mention` flag makes it free). - (c) As in (b), plus the terminal bell when a mention arrives. **Decided (a) for P1–P3, and (b) in P4, 2026-10-16.** A terminal user can't see a phone diff --git a/src/pepper_cli/api_client.py b/src/pepper_cli/api_client.py index 731860b..efcbe3f 100644 --- a/src/pepper_cli/api_client.py +++ b/src/pepper_cli/api_client.py @@ -1236,6 +1236,41 @@ class GroupSummary: topics: list[GroupTopicSummary] = field(default_factory=list) +@dataclass(frozen=True) +class GroupUnreadRow: + """One topic's unread answer, as the server computed it (read-state-sync §6). + + The server owns the rule: the floor, the person's own messages, tombstones and a carried + topic's reach are already applied. ``floor`` is informational. + """ + + topic_id: str + unread_count: int + has_unread_mention: bool = False + floor: str | None = None + + +@dataclass(frozen=True) +class GroupReadState: + """``GET /api/v1/groups/{gid}/read``: read marks plus the room's unread rows.""" + + marks: dict[str, str] = field(default_factory=dict) + unread: list[GroupUnreadRow] = field(default_factory=list) + + +def _unread_row_from_dict(raw: dict[str, Any]) -> GroupUnreadRow | None: + topic_id, count = raw.get("topic_id"), raw.get("unread_count") + if not isinstance(topic_id, str) or isinstance(count, bool) or not isinstance(count, int): + return None + floor = raw.get("floor") + return GroupUnreadRow( + topic_id=topic_id, + unread_count=max(count, 0), + has_unread_mention=raw.get("has_unread_mention") is True, + floor=floor if isinstance(floor, str) else None, + ) + + # MIRROR: src/pepper/api/groups.py::list_groups # (route handler; untyped dict response, no snapshot schema) @dataclass(frozen=True) @@ -3213,17 +3248,26 @@ def set_group_read_mark( json={"read_through": read_through}, ).json() - def get_group_read_marks(self, group_id: str) -> dict[str, str]: - """``GET /api/v1/groups/{gid}/read`` — ``{topic_id: read_through}`` for every - topic the caller has a mark on. A topic with no mark is absent, not null.""" + def get_group_read_state(self, group_id: str) -> GroupReadState: + """``GET /api/v1/groups/{gid}/read`` — the caller's read marks and, from a server that + has server-side unread (read-state-sync phase 5b), the unread rows for the room. + + A topic with no mark is absent from ``marks``, not null. A server without the + ``unread`` key yields no rows, which reads as "nothing known to be unread".""" # MIRROR: src/pepper/api/groups.py::read_marks # (route handler; the response is an untyped dict, so no snapshot schema) body = self._request("GET", f"/api/v1/groups/{group_id}/read").json() - return { + marks = { m["topic_id"]: m["read_through"] for m in body.get("marks") or [] if isinstance(m.get("topic_id"), str) and isinstance(m.get("read_through"), str) } + rows = [ + row + for raw in body.get("unread") or [] + if isinstance(raw, dict) and (row := _unread_row_from_dict(raw)) is not None + ] + return GroupReadState(marks=marks, unread=rows) def list_groups_conditional( self, *, if_none_match: str | None = None diff --git a/src/pepper_cli/tui/app.py b/src/pepper_cli/tui/app.py index 4593616..4b02d9b 100644 --- a/src/pepper_cli/tui/app.py +++ b/src/pepper_cli/tui/app.py @@ -81,6 +81,7 @@ GroupRosterMember, GroupSummary, GroupTopicSummary, + GroupUnreadRow, ) from pepper_cli.provider.fs import FilesystemProvider @@ -127,7 +128,6 @@ "newtopic", "info", "topics", - "mentions", "refresh", ) #: Transport failures that mean a turn's request never reached the server, so it is safe to @@ -216,8 +216,8 @@ def __init__( ) -> None: super().__init__() self._api = api - #: Ring the terminal bell when a mention is first found in another topic (§15 C, - #: opt-in only). + #: Ring the terminal bell when the server first reports an unread mention in another + #: topic (§15 C, opt-in only). self._bell = bell #: Hands a link to the system browser (``o``); a test passes a recorder. self._open_url = open_url @@ -237,9 +237,8 @@ def __init__( self.rooms: list[GroupSummary] = [] self.groups_enabled = True self.groups_degraded = False + #: The cache of the server's read state: its unread rows and read cursors. self.marks = state.ReadMarks() - #: Unread topics that mention you, found one fetch at a time (§15 C). - self.mentions = state.Mentions() self.current = state.PEPPER self.store = state.MessageStore() self.dm = state.DmTracker() @@ -542,8 +541,6 @@ def _run(self, kind: str) -> None: self._send_next() elif kind == "roster": self._fetch_roster() - elif kind == "mentions": - self._check_mentions() elif kind == "refresh": self._refresh_next() elif kind == "turn": @@ -652,6 +649,12 @@ def _on_stream_frame(self, frame: FeedFrame) -> None: stamp, done = state.parse_ts(raw), state.parse_ts(self._published.get(topic_id)) if isinstance(raw, str) and stamp is not None and (done is None or stamp > done): self._published[topic_id] = raw + self.marks.settle(self.rooms) + row = self.marks.unread.get(topic_id) + if row is not None and row.unread_count > 0: + # A partial read: how much is left is the server's to say. + self._rail.refetch_all() + self._want("rail") self.refresh_chrome() return if kind not in _ROOM_EVENT_KINDS: @@ -677,14 +680,10 @@ def _on_stream_frame(self, frame: FeedFrame) -> None: # ------------------------------------------------------------------ results def _learn_person_id(self, person_id: str) -> None: - """Keep your person id for the session; topics asked about before it was known are - asked again.""" + """Keep your person id for the session.""" if self.you_person_id is not None: return self.you_person_id = person_id - self.mentions.checked.clear() - if self._started: - self._run("mentions") def _learn_from_messages(self, messages: list[GroupMessage]) -> None: """Until whoami carries ``person_id``: your own message names you.""" @@ -703,38 +702,17 @@ def _on_rail(self, update: poller.RailUpdate) -> None: self.groups_enabled = groups.enabled self.groups_degraded = groups.degraded if groups.enabled and not groups.degraded: - self._apply_groups(groups, update.server_marks) + self._apply_groups(groups, update.server_marks, update.unread) - def _apply_groups(self, groups: GroupList, server_marks: dict[str, str]) -> None: + def _apply_groups( + self, groups: GroupList, server_marks: dict[str, str], unread: list[GroupUnreadRow] + ) -> None: self.rooms = groups.groups - self.marks.merge_server(server_marks) - self.marks.seed_first_sight(self.rooms) - # A failing mention loop keeps its backoff: new room activity waits for the retry. - mentions = self._loops["mentions"] - if mentions.failures == 0: - self._run("mentions") - elif mentions.timer is None and not mentions.busy: - self._schedule("mentions") - - def _check_mentions(self) -> None: - """Read the newest page of the next unread topic not yet asked about (one in flight). - - Each delivery asks again, so a burst of unread topics is worked off one request at a - time; a topic is asked about once per new activity (``Mentions.due``). - """ - if self.you_person_id is None: - return - due = self.mentions.due(self.rooms, self.marks, open_topic_id=self.current.topic_id) - if not due: - return - room_id, topic = due[0] - conv = state.Conversation(room_id, topic.topic_id) - self._start( - "mentions", - lambda: poller.fetch_topic(self._api, conv, since=None), - lambda page: self._mentions_checked(topic, page), - on_error=lambda exc: self._mentions_failed(topic, exc), - ) + had = self.marks.mentioned_topics() + self.marks.merge_unread(unread, server_marks) + self.marks.settle(self.rooms) + if self._bell and (self.marks.mentioned_topics() - had - {self.current.topic_id}): + self.bell() def _refresh_next(self) -> None: """Read the next changed row again: the window around it (``at``), from which only @@ -776,28 +754,6 @@ def _refresh_failed(self, message_id: str, exc: Exception) -> bool: self._stale_rows.insert(0, message_id) return False - def _mentions_checked(self, topic: GroupTopicSummary, page: GroupMessagePage) -> None: - found = self.mentions.record( - topic, - page.messages, - you_id=self.you_person_id, - mark=self.marks.marks.get(topic.topic_id), - ) - # A topic opened while this check was in flight has not raised its read mark yet - # (its page is still loading), so ``record`` can call it new: no bell for it. - if found and self._bell and topic.topic_id != self.current.topic_id: - self.bell() - self._run("mentions") - - def _mentions_failed(self, topic: GroupTopicSummary, exc: Exception) -> bool: - """A topic this token cannot read is dropped; anything else retries with backoff, - behind the other due topics so one failing topic does not hold them up.""" - if isinstance(exc, ApiError) and exc.status in _PUBLISH_PERMANENT: - self.mentions.skip(topic) - return True - self.mentions.defer(topic) - return False - def _on_dm(self, history: list[ChatHistoryEntry]) -> None: new_ids = self.dm.update(history, is_open=self.current.is_pepper) self._settle_turns() @@ -869,7 +825,7 @@ def _render_topic(self, *, new_rows: int, reset: bool, prepended: bool = False) def refresh_chrome(self) -> None: """Sidebar, header, notice, subline and terminal title from the current state.""" shown = self.rooms if self.groups_enabled else [] - self.marks.scrolled_up = self.current.topic_id if self._scrolled_up() else None + self.marks.settle(shown) current_key = "pepper" if self.current.is_pepper else f"topic:{self.current.topic_id}" self.sidebar.set_rows( state.build_sidebar( @@ -881,7 +837,6 @@ def refresh_chrome(self) -> None: pepper_dot=self.dm.dot, folds=self.folds, now=self._now(), - mentions=self.mentions, ), current_key=current_key, ) @@ -894,8 +849,8 @@ def refresh_chrome(self) -> None: box.update(Text(notice)) box.set_class(not notice, "empty") self._update_subline() - unread = state.any_unread(shown, self.marks, current=self.current, pepper_dot=self.dm.dot) - mention = state.any_mention(shown, self.marks, self.mentions, current=self.current) + unread = state.any_unread(shown, self.marks, pepper_dot=self.dm.dot) + mention = state.any_mention(shown, self.marks) self.title = ("@ " if mention else "● " if unread else "") + "Pepper" def _notice(self) -> str: diff --git a/src/pepper_cli/tui/poller.py b/src/pepper_cli/tui/poller.py index 6eba716..e47a83c 100644 --- a/src/pepper_cli/tui/poller.py +++ b/src/pepper_cli/tui/poller.py @@ -20,9 +20,11 @@ ChatHistoryEntry, GroupList, GroupMessage, + GroupReadState, GroupRosterMember, GroupSummary, GroupTopicSummary, + GroupUnreadRow, ) from pepper_cli.tui.state import Conversation, PendingSend @@ -46,6 +48,8 @@ class RailUpdate: groups: GroupList | None #: ``topic_id -> read_through`` for the rooms whose topics moved since the last 200. server_marks: dict[str, str] = field(default_factory=dict) + #: The server's unread rows for those rooms (read-state-sync §6). + unread: list[GroupUnreadRow] = field(default_factory=list) etag: str | None = None seen: dict[str, _Signature] | None = None #: The newest :meth:`RailPoller.refetch_all` request this step answered by reading every @@ -105,14 +109,17 @@ def step(self) -> RailUpdate: elif (old := self._seen.get(room.group_id)) is not None: seen[room.group_id] = old # its marks failed: it still counts as moved merged: dict[str, str] = {} - for marks in fetched.values(): - merged.update(marks) + rows: list[GroupUnreadRow] = [] + for answer in fetched.values(): + merged.update(answer.marks) + rows.extend(answer.unread) complete = len(fetched) == len(moved) # A room whose marks could not be read is asked again on the next poll. That needs a # 200, so the ETag is only kept when every room's marks came back. return RailUpdate( groups=groups, server_marks=merged, + unread=rows, etag=etag if complete else None, seen=seen, full=asked if full and complete else 0, @@ -129,8 +136,8 @@ def forget_etag(self) -> None: """Ask for the full list next time (after a failed poll, §6.6).""" self._etag = None - def _fetch_marks(self, rooms: list[GroupSummary]) -> dict[str, dict[str, str]]: - """``group_id -> marks`` for each room whose marks could be read.""" + def _fetch_marks(self, rooms: list[GroupSummary]) -> dict[str, GroupReadState]: + """``group_id -> read state`` (marks and unread rows) for each room that answered.""" if not rooms: return {} with ThreadPoolExecutor(max_workers=MARK_FETCH_WORKERS) as pool: @@ -141,12 +148,13 @@ def _fetch_marks(self, rooms: list[GroupSummary]) -> dict[str, dict[str, str]]: if marks is not None } - def _marks_or_none(self, group_id: str) -> dict[str, str] | None: - # One room's marks failing must not hide the rest of the rail. A room whose marks - # cannot be read is seeded read on first sight and asked again on the next poll. A - # transport error is not caught: the whole step fails and nothing is committed. + def _marks_or_none(self, group_id: str) -> GroupReadState | None: + # One room's read state failing must not hide the rest of the rail. A room that cannot + # be read keeps whatever the cache held (nothing, on first sight: not unread) and is + # asked again on the next poll. A transport error is not caught: the whole step fails + # and nothing is committed. try: - return self._api.get_group_read_marks(group_id) + return self._api.get_group_read_state(group_id) except ApiError as exc: if exc.status == 401: raise diff --git a/src/pepper_cli/tui/state.py b/src/pepper_cli/tui/state.py index 1b82723..93fd7f5 100644 --- a/src/pepper_cli/tui/state.py +++ b/src/pepper_cli/tui/state.py @@ -1,8 +1,8 @@ -"""Pure logic of the terminal UI: ordering, the unread rule, cursors, cadence, backoff. +"""Pure logic of the terminal UI: ordering, the unread cache, cursors, cadence, backoff. Nothing here imports Textual or touches the network, so it is unit-testable on its own and carries most of the package's coverage (``planning/terminal-ui.md`` §11). The rules are ported -from the web client: ``groups_unread.js`` / ``groups_read_marks.js`` (unread, read marks), +from the web client: ``groups_read_marks.js`` (read marks; unread itself is the server's), ``group_room.js`` (the poll cursor, pepper#4829) and ``group_poll_cadence.js`` (cadence). Every timestamp comparison goes through :func:`parse_ts`. The server spells the same instant @@ -28,6 +28,7 @@ GroupRosterMember, GroupSummary, GroupTopicSummary, + GroupUnreadRow, ) #: How far behind the newest ``created_at`` a topic poll asks (§6.2, pepper#4829). A row that @@ -115,16 +116,20 @@ def is_pepper(self) -> bool: @dataclass class ReadMarks: - """Per-topic read marks held in memory for the session (§6.3). - - The server is the store of record; this is its mirror plus the first-sight seed. Merges - are always "take the max", so a late answer can never walk a mark backwards. + """The session's cache of the server's read state (§6.3, read-state-sync §6). + + The server decides what is unread (its floor, your own messages, tombstones, a carried + topic's reach) and answers it as ``unread`` rows; this holds the latest answer per topic + and the read cursors that came with it. The one thing the client adds is that a cursor can + only ever clear: a mark at or after a topic's newest activity leaves nothing in it unread + under any rule, so :meth:`settle` drops the cached row until the server's next answer. + Nothing here ever *raises* a count, and a topic the server has no row for is not unread. + Mark merges are "take the max", so a late answer cannot walk a mark backwards. """ marks: dict[str, datetime] = field(default_factory=dict) - #: The open topic while its thread is scrolled up (§5.2): rows below the screen have not been - #: seen, so it stays subject to the unread rule instead of counting as read for being open. - scrolled_up: str | None = None + #: ``topic_id -> the server's unread row`` (zero-count rows are kept: they say "read"). + unread: dict[str, GroupUnreadRow] = field(default_factory=dict) def merge(self, topic_id: str, at: datetime | None) -> bool: """Raise the mark for ``topic_id`` to ``at`` if that is newer. True when it moved.""" @@ -140,6 +145,20 @@ def merge_server(self, server_marks: dict[str, str]) -> None: for topic_id, raw in server_marks.items(): self.merge(topic_id, parse_ts(raw)) + def merge_unread(self, rows: Iterable[GroupUnreadRow], marks: Mapping[str, str]) -> None: + """Take the server's unread rows (and the cursors answered with them). + + A row older than a cursor already held here (an answer that crossed a read on this + device or another) is stale for that topic and is skipped; the next answer covers it. + """ + self.merge_server(dict(marks)) + for row in rows: + held = self.marks.get(row.topic_id) + answered = parse_ts(marks.get(row.topic_id)) + if held is not None and (answered is None or answered < held): + continue + self.unread[row.topic_id] = row + def merge_advanced(self, payload: object) -> str | None: """Fold a ``read_state.advanced`` event payload (``{topic_id, group_id, last_read_at}``) into the marks. Forward only, and at the server's own precision: ``last_read_at`` is a @@ -156,127 +175,46 @@ def merge_advanced(self, payload: object) -> str | None: return None return topic_id if self.merge(topic_id, parse_ts(raw)) else None - def seed_first_sight(self, rooms: Iterable[GroupSummary]) -> None: - """A topic with no mark is read as of its current activity, so a fresh install does - not light up every room (the web does the same).""" + def settle(self, rooms: Iterable[GroupSummary]) -> bool: + """Clear the cached row of every topic whose mark has passed its newest activity. + + True when a row was cleared. A mark short of the activity (a partial read) clears + nothing: the count is the server's, and the caller asks it again. + """ + cleared = False for room in rooms: for topic in room.topics: - if topic.topic_id not in self.marks: - self.merge(topic.topic_id, parse_ts(topic.last_activity_at)) - - def is_unread(self, topic: GroupTopicSummary, *, open_topic_id: str | None) -> bool: - """The unread rule: activity later than the mark, and not the topic on screen (unless - its thread is scrolled up, ``scrolled_up``). - - An own send does not light a topic it was sent from at the thread's end: a pending row is - not in the rail at all, and once the server stores it the app raises the mark to that - row's ``created_at`` (:meth:`PepperApp._sent`), so the activity it causes is already - read. A send that lands after the person scrolled up, looked away or left the topic marks - nothing, since rows stored before it have not been on screen. - """ - if topic.archived_at: - return False - if topic.topic_id == open_topic_id and topic.topic_id != self.scrolled_up: - return False - activity = parse_ts(topic.last_activity_at) - if activity is None: - return False - mark = self.marks.get(topic.topic_id) - return mark is None or activity > mark + row = self.unread.get(topic.topic_id) + if row is None or (row.unread_count == 0 and not row.has_unread_mention): + continue + mark, activity = self.marks.get(topic.topic_id), parse_ts(topic.last_activity_at) + if mark is not None and activity is not None and mark >= activity: + self.unread[topic.topic_id] = replace( + row, unread_count=0, has_unread_mention=False + ) + cleared = True + return cleared - def room_unread(self, room: GroupSummary, *, open_topic_id: str | None) -> bool: - return any(self.is_unread(t, open_topic_id=open_topic_id) for t in room.topics) + def is_unread(self, topic: GroupTopicSummary) -> bool: + """The server says this topic has unread messages. Archived topics are not shown as + unread (the server counts them; the rail hides them).""" + row = self.unread.get(topic.topic_id) + return bool(row and row.unread_count > 0 and not topic.archived_at) + def is_mentioned(self, topic: GroupTopicSummary) -> bool: + """The server says an unread message in this topic mentions you.""" + row = self.unread.get(topic.topic_id) + return bool(row and row.has_unread_mention and self.is_unread(topic)) -@dataclass -class Mentions: - """Which unread topics hold a message that mentions you (§6.3, §15 C). - - The rooms list gives only ``last_activity_at``, so a topic is asked about once per new - activity: when it is unread and its activity is later than the activity last asked about. - The answer is the newest page of its messages, read for rows after the read mark that - carry your person id in ``mentions``. A topic with more unread rows than one page holds - can miss an older mention; the open topic needs none of this (its rows show the accent). - """ + def room_unread(self, room: GroupSummary) -> bool: + return any(self.is_unread(t) for t in room.topics) - #: ``topic_id -> last_activity_at`` the topic was last asked about. - checked: dict[str, datetime] = field(default_factory=dict) - #: ``topic_id -> created_at`` of the newest unread message found that mentions you. A hit - #: shows only while the read mark is before it, so reading the topic retires it. - hits: dict[str, datetime] = field(default_factory=dict) - #: ``topic_id -> order`` of topics whose last ask failed and will be retried; they go - #: behind every other due topic so one failing topic cannot hold the rest up. - deferred: dict[str, int] = field(default_factory=dict) - _deferrals: int = 0 - - def due( - self, rooms: Iterable[GroupSummary], marks: ReadMarks, *, open_topic_id: str | None - ) -> list[tuple[str, GroupTopicSummary]]: - """``(room_id, topic)`` pairs to ask about: sidebar order, retried topics last.""" - found: list[tuple[str, GroupTopicSummary]] = [] - for room in rooms: - for topic in topics_by_recency(room): - activity = parse_ts(topic.last_activity_at) - if activity is None or not marks.is_unread(topic, open_topic_id=open_topic_id): - continue - if activity > self.checked.get(topic.topic_id, _EPOCH): - found.append((room.group_id, topic)) - return sorted(found, key=lambda pair: self.deferred.get(pair[1].topic_id, 0)) - - def record( - self, - topic: GroupTopicSummary, - messages: Iterable[GroupMessage], - *, - you_id: str | None, - mark: datetime | None, - ) -> bool: - """Take the answer for ``topic``; True when it holds a mention newer than any known.""" - self._answered(topic) - newest: datetime | None = None - if you_id: - for m in messages: - if you_id not in m.mentions or m.is_you or m.is_deleted: - continue - at = parse_ts(m.created_at) or _EPOCH - if (mark is None or at > mark) and (newest is None or at > newest): - newest = at - if newest is None: - self.hits.pop(topic.topic_id, None) - return False - was = self.hits.get(topic.topic_id) - self.hits[topic.topic_id] = newest - return was is None or newest > was - - def skip(self, topic: GroupTopicSummary) -> None: - """The topic cannot be read (no access, gone): stop asking about this activity.""" - self._answered(topic) - self.hits.pop(topic.topic_id, None) - - def defer(self, topic: GroupTopicSummary) -> None: - """Asking about ``topic`` failed and will be retried: put it behind the others.""" - self._deferrals += 1 - self.deferred[topic.topic_id] = self._deferrals - - def _answered(self, topic: GroupTopicSummary) -> None: - activity = parse_ts(topic.last_activity_at) - if activity is not None: - self.checked[topic.topic_id] = activity - self.deferred.pop(topic.topic_id, None) - - def topic_mentioned( - self, topic: GroupTopicSummary, marks: ReadMarks, *, open_topic_id: str | None - ) -> bool: - at = self.hits.get(topic.topic_id) - if at is None or not marks.is_unread(topic, open_topic_id=open_topic_id): - return False - mark = marks.marks.get(topic.topic_id) - return mark is None or at > mark + def room_mentioned(self, room: GroupSummary) -> bool: + return any(self.is_mentioned(t) for t in room.topics) - def room_mentioned( - self, room: GroupSummary, marks: ReadMarks, *, open_topic_id: str | None - ) -> bool: - return any(self.topic_mentioned(t, marks, open_topic_id=open_topic_id) for t in room.topics) + def mentioned_topics(self) -> set[str]: + """Topic ids whose unread rows carry a mention (for spotting a new one).""" + return {t for t, row in self.unread.items() if row.has_unread_mention} def topics_by_recency(room: GroupSummary) -> list[GroupTopicSummary]: @@ -362,7 +300,6 @@ def build_sidebar( pepper_dot: bool, folds: SidebarFolds | None = None, now: datetime | None = None, - mentions: Mentions | None = None, ) -> list[SidebarRow]: """The Messages tree as flat rows: Pepper first, then rooms in server (recency) order. @@ -373,8 +310,6 @@ def build_sidebar( (never the open room, nor one with something unread); without it nothing folds. """ folds = folds or SidebarFolds() - mentions = mentions or Mentions() - open_topic_id = current.topic_id rows = [SidebarRow(key="pepper", kind="pepper", label="Pepper", depth=0, unread=pepper_dot)] folded: set[str] = set() if now is not None: @@ -383,7 +318,7 @@ def build_sidebar( for r in rooms if room_inactive(r, now=now) and r.group_id != current.room_id - and not marks.room_unread(r, open_topic_id=open_topic_id) + and not marks.room_unread(r) } for room in rooms: if room.group_id in folded and not folds.inactive: @@ -397,8 +332,8 @@ def build_sidebar( depth=0, room_id=room.group_id, color=room.color, - unread=marks.room_unread(room, open_topic_id=open_topic_id), - mention=mentions.room_mentioned(room, marks, open_topic_id=open_topic_id), + unread=marks.room_unread(room), + mention=marks.room_mentioned(room), expandable=True, expanded=expanded, ) @@ -408,7 +343,6 @@ def build_sidebar( _room_children( room, marks, - mentions, current=current, folds=folds, show_all=room.group_id in show_all, @@ -432,7 +366,6 @@ def build_sidebar( def _room_children( room: GroupSummary, marks: ReadMarks, - mentions: Mentions, *, current: Conversation, folds: SidebarFolds, @@ -453,8 +386,8 @@ def _room_children( depth=1, room_id=room.group_id, topic_id=t.topic_id, - unread=marks.is_unread(t, open_topic_id=open_topic_id), - mention=mentions.topic_mentioned(t, marks, open_topic_id=open_topic_id), + unread=marks.is_unread(t), + mention=marks.is_mentioned(t), ) for t in shown ] @@ -517,7 +450,7 @@ def unread_conversations( found.extend( Conversation(room.group_id, t.topic_id) for t in topics_by_recency(room) - if marks.is_unread(t, open_topic_id=current.topic_id) + if marks.is_unread(t) ) return found @@ -561,22 +494,14 @@ def header_text(current: Conversation, rooms: list[GroupSummary]) -> str: return "" -def any_mention( - rooms: list[GroupSummary], marks: ReadMarks, mentions: Mentions, *, current: Conversation -) -> bool: +def any_mention(rooms: list[GroupSummary], marks: ReadMarks) -> bool: """Whether any unread topic holds a mention of you (the terminal title shows ``@``).""" - return any( - mentions.room_mentioned(room, marks, open_topic_id=current.topic_id) for room in rooms - ) + return any(marks.room_mentioned(room) for room in rooms) -def any_unread( - rooms: list[GroupSummary], marks: ReadMarks, *, current: Conversation, pepper_dot: bool -) -> bool: +def any_unread(rooms: list[GroupSummary], marks: ReadMarks, *, pepper_dot: bool) -> bool: """Whether the terminal title should carry the ``●`` prefix.""" - return pepper_dot or any( - marks.room_unread(room, open_topic_id=current.topic_id) for room in rooms - ) + return pepper_dot or any(marks.room_unread(room) for room in rooms) # --------------------------------------------------------------------------- diff --git a/tests/test_api_client_groups.py b/tests/test_api_client_groups.py index 48f1c99..89e0c43 100644 --- a/tests/test_api_client_groups.py +++ b/tests/test_api_client_groups.py @@ -815,3 +815,43 @@ def handler(request: httpx.Request) -> httpx.Response: ("GET", f"{base}/sharing", ""), ("POST", f"{base}/sharing", '{"calendars":{"c1":"busy"}}'), ] + + +def test_get_group_read_state_parses_marks_and_the_servers_unread_rows() -> None: + seen: list[httpx.Request] = [] + body = { + "marks": [{"topic_id": _TID, "read_through": "2026-09-16T10:05:00+00:00"}], + "sync_dismissal": True, + "unread": [ + { + "topic_id": _TID, + "unread_count": 2, + "has_unread_mention": True, + "floor": "2026-01-01T00:00:00+00:00", + "read_through": None, + "a_future_field": 1, + }, + {"topic_id": "t2", "unread_count": 0}, # optional fields default + {"topic_id": "t3", "unread_count": "many"}, # malformed rows are dropped + {"unread_count": 1}, + "nonsense", + ], + } + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, json=body) + + state = _client(handler).get_group_read_state(_GID) + assert (seen[0].method, seen[0].url.path) == ("GET", f"/api/v1/groups/{_GID}/read") + assert state.marks == {_TID: "2026-09-16T10:05:00+00:00"} + assert state.unread == [ + api_client.GroupUnreadRow(_TID, 2, True, "2026-01-01T00:00:00+00:00"), + api_client.GroupUnreadRow("t2", 0, False, None), + ] + + +def test_get_group_read_state_from_a_server_without_unread_rows_has_none() -> None: + client = _client(lambda request: httpx.Response(200, json={"marks": []})) + state = client.get_group_read_state(_GID) + assert state.marks == {} and state.unread == [] diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index 435abd9..f8f9cad 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -97,8 +97,9 @@ class NotCheckable: route=("GET", "/api/v1/groups/{group_id}/roster"), ), ("api_client.py", "src/pepper/api/groups.py::read_marks"): NotCheckable( - "Route handler behind ``get_group_read_marks`` " - "(``{marks: [{topic_id, read_through}]}``). " + "Route handler behind ``get_group_read_state`` " + "(``{marks: [{topic_id, read_through}], unread: [{topic_id, unread_count, " + "has_unread_mention, floor}]}``). " f"{_UNTYPED}", route=("GET", "/api/v1/groups/{group_id}/read"), ), diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index e37ae1a..8b9fd40 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -221,6 +221,7 @@ async def test_a_rail_poll_in_flight_when_focus_returns_is_not_duplicated_or_los await until(pilot, lambda: len(app.rooms) == 2) gate = threading.Event() server.gates["/api/v1/groups"] = gate + server.messages[ts.TOPIC_B1].append(ts.message("m-new", "2026-09-16T18:00:00+00:00", "hi")) server.rooms[1]["topics"][0]["last_activity_at"] = "2026-09-16T18:00:00+00:00" server.bump() before = _count(server, "/api/v1/groups") @@ -234,7 +235,7 @@ async def test_a_rail_poll_in_flight_when_focus_returns_is_not_duplicated_or_los # Its result is applied (not dropped as stale behind a saved ETag): room B lights. await until( pilot, - lambda: state.any_unread(app.rooms, app.marks, current=app.current, pepper_dot=False), + lambda: state.any_unread(app.rooms, app.marks, pepper_dot=False), ) diff --git a/tests/test_tui_mentions.py b/tests/test_tui_mentions.py index 8fdafd8..7714df7 100644 --- a/tests/test_tui_mentions.py +++ b/tests/test_tui_mentions.py @@ -1,14 +1,13 @@ -"""P4: ``@`` on a sidebar row whose unread activity mentions you (planning/terminal-ui.md §15 C).""" +"""``@`` on a sidebar row whose unread activity mentions you: the server's ``has_unread_mention`` +(planning/terminal-ui.md §15 C, read-state-sync §6).""" from __future__ import annotations import io -import threading import pytest from rich.console import Console -from pepper_cli import api_client from pepper_cli.tui import app as tui_app from pepper_cli.tui import state from pepper_cli.tui.sidebar import row_label @@ -55,7 +54,6 @@ async def test_a_mention_in_an_unread_topic_shows_an_at_instead_of_the_dot() -> await until(pilot, lambda: len(app.rooms) == 2) _activity(server, ts.TOPIC_B1, 1, mentions=[ts.ME_PERSON]) room_key = f"room:{ts.ROOM_B}" - await until(pilot, lambda: ts.TOPIC_B1 in app.mentions.hits) await until(pilot, lambda: app.sidebar._by_key[room_key].data.mention) assert _label(app, room_key).endswith(" @") assert "●" not in _label(app, room_key) @@ -66,7 +64,7 @@ async def test_a_mention_in_an_unread_topic_shows_an_at_instead_of_the_dot() -> await until(pilot, lambda: app.title == "Pepper") -async def test_unread_without_a_mention_keeps_the_dot_and_costs_one_fetch() -> None: +async def test_unread_without_a_mention_keeps_the_dot_and_reads_no_messages() -> None: server = ts.standard_server() app = tui_app.PepperApp(server.client()) async with app.run_test(size=(120, 40)) as pilot: @@ -74,72 +72,24 @@ async def test_unread_without_a_mention_keeps_the_dot_and_costs_one_fetch() -> N _activity(server, ts.TOPIC_B1, 1) room_key = f"room:{ts.ROOM_B}" await until(pilot, lambda: app.sidebar._by_key[room_key].data.unread) - await until(pilot, lambda: ts.TOPIC_B1 in app.mentions.checked) assert _label(app, room_key).endswith(" ●") assert not app.sidebar._by_key[room_key].data.mention assert app.title == "● Pepper" - # Several rail polls later the topic has still been fetched exactly once. - await pilot.pause(0.4) - assert _fetches(server, ts.TOPIC_B1) == 1 - - -async def test_a_topic_that_cannot_be_read_is_dropped_not_retried() -> None: - server = ts.standard_server() - server.fail[f"/api/v1/groups/{ts.ROOM_B}/topics/{ts.TOPIC_B1}/messages"] = 403 - app = tui_app.PepperApp(server.client()) - async with app.run_test(size=(120, 40)) as pilot: - await until(pilot, lambda: len(app.rooms) == 2) - _activity(server, ts.TOPIC_B1, 1, mentions=[ts.ME_PERSON]) - await until(pilot, lambda: ts.TOPIC_B1 in app.mentions.checked) + # The flag is the server's: the client never fetches a topic's messages to look. await pilot.pause(0.3) - assert _fetches(server, ts.TOPIC_B1) == 1 - assert ts.TOPIC_B1 not in app.mentions.hits - - -async def test_a_transport_failure_retries_with_backoff_and_then_finds_the_mention() -> None: - server = ts.standard_server() - app = tui_app.PepperApp(server.client()) - async with app.run_test(size=(120, 40)) as pilot: - await until(pilot, lambda: len(app.rooms) == 2) - server.fail[f"/api/v1/groups/{ts.ROOM_B}/topics/{ts.TOPIC_B1}/messages"] = 500 - _activity(server, ts.TOPIC_B1, 1, mentions=[ts.ME_PERSON]) - await until(pilot, lambda: _fetches(server, ts.TOPIC_B1) >= 2) - server.fail.clear() - await until(pilot, lambda: ts.TOPIC_B1 in app.mentions.hits) - - -async def test_one_failing_topic_does_not_hold_up_mentions_in_the_others() -> None: - server = ts.standard_server() - app = tui_app.PepperApp(server.client()) - async with app.run_test(size=(120, 40)) as pilot: - await until(pilot, lambda: len(app.rooms) == 2) - # A1 sorts first and keeps failing; B1's mention must still be found. - server.fail[f"/api/v1/groups/{ts.ROOM_A}/topics/{ts.TOPIC_A1}/messages"] = 500 - server.messages[ts.TOPIC_A1].append(ts.message("m-a", NEW, "hi")) - server.rooms[0]["topics"][0]["last_activity_at"] = NEW - _activity(server, ts.TOPIC_B1, 1, mentions=[ts.ME_PERSON]) - await until(pilot, lambda: ts.TOPIC_B1 in app.mentions.hits) - assert _fetches(server, ts.TOPIC_A1) >= 1 - assert ts.TOPIC_A1 not in app.mentions.checked + assert _fetches(server, ts.TOPIC_B1) == 0 -async def test_room_updates_do_not_cut_a_mention_retry_short(monkeypatch) -> None: - monkeypatch.setattr(state, "BACKOFF_STEPS", (30.0,)) +async def test_no_marker_for_a_mention_of_someone_else() -> None: server = ts.standard_server() app = tui_app.PepperApp(server.client()) async with app.run_test(size=(120, 40)) as pilot: await until(pilot, lambda: len(app.rooms) == 2) - server.fail[f"/api/v1/groups/{ts.ROOM_B}/topics/{ts.TOPIC_B1}/messages"] = 500 - _activity(server, ts.TOPIC_B1, 1, mentions=[ts.ME_PERSON]) - await until(pilot, lambda: app._loops["mentions"].failures == 1) - # More room activity arrives on every rail poll while the retry waits. - for i in range(4): - at = f"2026-09-16T18:0{i + 1}:00+00:00" - server.rooms[0]["topics"][0]["last_activity_at"] = at - server.bump() - await until(pilot, lambda at=at: app.rooms[0].topics[0].last_activity_at == at) - assert _fetches(server, ts.TOPIC_B1) == 1 - assert app._loops["mentions"].timer is not None + _activity(server, ts.TOPIC_B1, 1, mentions=["00000000-0000-0000-0000-00000000cccc"]) + room_key = f"room:{ts.ROOM_B}" + await until(pilot, lambda: app.sidebar._by_key[room_key].data.unread) + assert not app.sidebar._by_key[room_key].data.mention + assert _label(app, room_key).endswith(" ●") @pytest.mark.parametrize("enabled", [False, True]) @@ -151,45 +101,23 @@ async def test_the_bell_rings_once_per_new_mention_and_only_when_asked(enabled) async with app.run_test(size=(120, 40)) as pilot: await until(pilot, lambda: len(app.rooms) == 2) _activity(server, ts.TOPIC_B1, 1, mentions=[ts.ME_PERSON]) - await until(pilot, lambda: ts.TOPIC_B1 in app.mentions.hits) - await pilot.pause(0.3) + await until(pilot, lambda: app.title == "@ Pepper") + await pilot.pause(0.3) # later polls repeat the same flag assert len(rings) == (1 if enabled else 0) -class _FirstThenRest(threading.Event): - """A gate that holds the first request on ``first`` and every later one on ``rest``.""" - - def __init__(self) -> None: - super().__init__() - self.first, self.rest = threading.Event(), threading.Event() - self._lock = threading.Lock() - self._seen = 0 - - def wait(self, timeout: float | None = None) -> bool: - with self._lock: - gate = self.first if self._seen == 0 else self.rest - self._seen += 1 - return gate.wait(timeout) - - -async def test_the_bell_stays_quiet_for_a_topic_opened_while_its_check_was_in_flight() -> None: +async def test_the_bell_stays_quiet_for_a_mention_in_the_open_topic() -> None: server = ts.standard_server() - gate = _FirstThenRest() - server.gates[f"GET /api/v1/groups/{ts.ROOM_B}/topics/{ts.TOPIC_B1}/messages"] = gate app = tui_app.PepperApp(server.client(), bell=True) rings: list[int] = [] app.bell = lambda: rings.append(1) # type: ignore[method-assign] async with app.run_test(size=(120, 40)) as pilot: await until(pilot, lambda: len(app.rooms) == 2) - _activity(server, ts.TOPIC_B1, 1, mentions=[ts.ME_PERSON]) - await until(pilot, lambda: _fetches(server, ts.TOPIC_B1) == 1) # the mention check - # Opened before the check answers; its own page (and read mark) is still on the way. app.open_conversation(state.Conversation(ts.ROOM_B, ts.TOPIC_B1)) - await until(pilot, lambda: _fetches(server, ts.TOPIC_B1) == 2) - gate.first.set() - await until(pilot, lambda: ts.TOPIC_B1 in app.mentions.checked) - gate.rest.set() + await until(pilot, lambda: app.loaded_topic) + _activity(server, ts.TOPIC_B1, 1, mentions=[ts.ME_PERSON]) await until(pilot, lambda: "ping" in ts.body_text(app)) + await pilot.pause(0.3) assert rings == [] @@ -223,72 +151,6 @@ def test_the_fixture_keeps_user_id_and_person_id_apart() -> None: assert ts.ME != ts.ME_PERSON -async def test_whoami_person_id_is_what_mentions_are_matched_against() -> None: - server = ts.standard_server() - app = tui_app.PepperApp(server.client()) - async with app.run_test(size=(120, 40)) as pilot: - await until(pilot, lambda: len(app.rooms) == 2) - assert app.you_person_id == ts.ME_PERSON - # The core user id is not in any group payload; a mention carrying it is not yours. - _activity(server, ts.TOPIC_B1, 1, mentions=[ts.ME]) - await until(pilot, lambda: ts.TOPIC_B1 in app.mentions.checked) - assert ts.TOPIC_B1 not in app.mentions.hits - - -async def test_no_marker_for_a_mention_of_someone_else() -> None: - server = ts.standard_server() - app = tui_app.PepperApp(server.client()) - async with app.run_test(size=(120, 40)) as pilot: - await until(pilot, lambda: len(app.rooms) == 2) - _activity(server, ts.TOPIC_B1, 1, mentions=["00000000-0000-0000-0000-00000000cccc"]) - room_key = f"room:{ts.ROOM_B}" - await until(pilot, lambda: ts.TOPIC_B1 in app.mentions.checked) - assert not app.sidebar._by_key[room_key].data.mention - assert _label(app, room_key).endswith(" ●") - - -async def test_without_a_whoami_person_id_it_is_learned_from_your_own_message() -> None: - server = ts.standard_server() - server.person_id = None # a server that does not send person_id yet - server.messages[ts.TOPIC_A1].append(_own("m-own", "2026-09-16T17:31:00+00:00")) - app = tui_app.PepperApp(server.client()) - async with app.run_test(size=(120, 40)) as pilot: - await until(pilot, lambda: len(app.rooms) == 2) - assert app.you_person_id is None - # Nothing is asked about while the id is unknown. - _activity(server, ts.TOPIC_B1, 1, mentions=[ts.ME_PERSON]) - await pilot.pause(0.3) - assert _fetches(server, ts.TOPIC_B1) == 0 - # Opening a topic that holds a message of yours teaches the id; the unread topic is - # then asked about and gets its marker. - app.open_conversation(state.Conversation(ts.ROOM_A, ts.TOPIC_A1)) - await until(pilot, lambda: app.you_person_id == ts.ME_PERSON) - room_key = f"room:{ts.ROOM_B}" - await until(pilot, lambda: ts.TOPIC_B1 in app.mentions.hits) - await until(pilot, lambda: app.sidebar._by_key[room_key].data.mention) - assert _label(app, room_key).endswith(" @") - - -async def test_a_learned_person_id_is_kept_and_reasks_topics_already_checked() -> None: - server = ts.standard_server() - app = tui_app.PepperApp(server.client()) - async with app.run_test(size=(120, 40)) as pilot: - await until(pilot, lambda: len(app.rooms) == 2) - app.you_person_id = None - _activity(server, ts.TOPIC_B1, 1, mentions=[ts.ME_PERSON]) - # Checked while the id was unknown, as an older client would have recorded it. - app.mentions.checked[ts.TOPIC_B1] = state.parse_ts(NEW) # type: ignore[assignment] - app._learn_from_messages( - [api_client.group_message_from_dict(_own("m-own", "2026-09-16T17:31:00+00:00"))] - ) - assert app.you_person_id == ts.ME_PERSON - await until(pilot, lambda: ts.TOPIC_B1 in app.mentions.hits) - # Kept for the session: a later message of another author does not change it. - other = ts.message("m-x", NEW, "x", is_you=True, author_person_id="someone-else") - app._learn_from_messages([api_client.group_message_from_dict(other)]) - assert app.you_person_id == ts.ME_PERSON - - async def test_the_thread_highlights_a_mention_by_person_id() -> None: server = ts.standard_server() server.messages[ts.TOPIC_A1].append( diff --git a/tests/test_tui_poller.py b/tests/test_tui_poller.py index 64df1eb..7367d40 100644 --- a/tests/test_tui_poller.py +++ b/tests/test_tui_poller.py @@ -206,3 +206,14 @@ def test_fetch_dm_and_publish() -> None: ) assert poller.publish_read_mark(api, state.PEPPER, "x") == "x" assert server.puts == [(ts.TOPIC_A1, "2026-09-16T18:00:00+00:00")] + + +def test_rail_step_carries_the_servers_unread_rows_for_the_rooms_it_read() -> None: + server = ts.standard_server() + server.marks = {} + server.floor = "2026-09-16T17:00:00+00:00" + server.messages[ts.TOPIC_B1] = [ts.message("b1", "2026-09-16T17:30:00+00:00", "hi")] + with server.client() as api: + update = poller.RailPoller(api).step() + by_topic = {row.topic_id: row.unread_count for row in update.unread} + assert by_topic == {ts.TOPIC_A1: 1, ts.TOPIC_A2: 0, ts.TOPIC_B1: 1} diff --git a/tests/test_tui_state.py b/tests/test_tui_state.py index f399d90..9e97807 100644 --- a/tests/test_tui_state.py +++ b/tests/test_tui_state.py @@ -25,19 +25,20 @@ def test_parse_ts_reads_both_spellings_as_the_same_instant() -> None: assert state.parse_ts(None) is None -def test_marks_only_move_forward_and_first_sight_seeds_read() -> None: +def _row(topic_id: str, count: int, *, mention: bool = False) -> api_client.GroupUnreadRow: + return api_client.GroupUnreadRow(topic_id, count, has_unread_mention=mention) + + +def test_marks_only_move_forward() -> None: marks = state.ReadMarks() later, earlier = datetime(2026, 1, 2, tzinfo=UTC), datetime(2026, 1, 1, tzinfo=UTC) assert marks.merge("t", later) is True assert marks.merge("t", earlier) is False assert marks.merge("t", None) is False assert marks.marks["t"] == later - rooms = _rooms(ts.room(ts.ROOM_A, "A", [ts.topic("t2", "New", "2026-02-01T00:00:00+00:00")])) - marks.seed_first_sight(rooms) - assert not marks.is_unread(rooms[0].topics[0], open_topic_id=None) -def test_unread_rule_activity_after_mark_and_not_open_or_archived() -> None: +def test_unread_is_the_servers_row_and_a_topic_it_has_no_row_for_is_read() -> None: (room,) = _rooms( ts.room( ts.ROOM_A, @@ -45,19 +46,45 @@ def test_unread_rule_activity_after_mark_and_not_open_or_archived() -> None: [ ts.topic("t1", "One", "2026-02-01T00:00:00Z"), ts.topic("t2", "Two", "2026-02-01T00:00:00Z", archived_at="2026-02-02T00:00:00Z"), - ts.topic("t3", "Three", ""), + ts.topic("t3", "Three", "2026-02-01T00:00:00Z"), + ts.topic("t4", "Four", "2026-02-01T00:00:00Z"), ], ) ) marks = state.ReadMarks() - marks.merge_server({"t1": "2026-01-31T00:00:00+00:00"}) - t1, t2, t3 = room.topics - assert marks.is_unread(t1, open_topic_id=None) - assert not marks.is_unread(t1, open_topic_id="t1") # on screen - assert not marks.is_unread(t2, open_topic_id=None) # archived - assert not marks.is_unread(t3, open_topic_id=None) # no activity stamp - marks.merge_server({"t1": "2026-02-01T00:00:00+00:00"}) # same instant, other spelling - assert not marks.is_unread(t1, open_topic_id=None) + marks.merge_unread([_row("t1", 2), _row("t2", 1), _row("t4", 0)], {}) + t1, t2, t3, t4 = room.topics + assert marks.is_unread(t1) + assert not marks.is_unread(t2) # archived: counted by the server, hidden by the rail + assert not marks.is_unread(t3) # no row: unknown is not unread + assert not marks.is_unread(t4) # the server says zero + # Activity later than the cursor is not the rule any more: only the server's count is. + marks.merge_server({"t3": "2026-01-01T00:00:00Z"}) + assert not marks.is_unread(t3) + + +def test_a_cursor_past_the_activity_clears_a_row_and_a_partial_one_does_not() -> None: + (room,) = _rooms(ts.room(ts.ROOM_A, "A", [ts.topic("t1", "One", "2026-02-01T00:00:00Z")])) + (topic,) = room.topics + marks = state.ReadMarks() + marks.merge_unread([_row("t1", 3, mention=True)], {"t1": "2026-01-01T00:00:00Z"}) + assert marks.is_mentioned(topic) + marks.merge_advanced({"topic_id": "t1", "last_read_at": "2026-01-31T00:00:00Z"}) + assert marks.settle([room]) is False # a partial read: the count is the server's + assert marks.is_unread(topic) + marks.merge_advanced({"topic_id": "t1", "last_read_at": "2026-02-01T00:00:00+00:00"}) + assert marks.settle([room]) is True + assert not marks.is_unread(topic) and not marks.is_mentioned(topic) + assert marks.settle([room]) is False # nothing left to clear + + +def test_a_stale_answer_does_not_relight_a_topic_read_since() -> None: + marks = state.ReadMarks() + marks.merge("t1", state.parse_ts("2026-02-01T00:00:00Z")) + marks.merge_unread([_row("t1", 2)], {"t1": "2026-01-01T00:00:00Z"}) # read before the cursor + assert "t1" not in marks.unread + marks.merge_unread([_row("t1", 1)], {"t1": "2026-02-01T00:00:00Z"}) + assert marks.unread["t1"].unread_count == 1 def _big_room() -> list[api_client.GroupSummary]: @@ -138,18 +165,18 @@ def test_next_unread_wraps_both_ways_and_reports_none() -> None: ts.room(ts.ROOM_B, "B", [ts.topic("b1", "B1", "2026-02-02T00:00:00Z")]), ) marks = state.ReadMarks() - marks.merge_server({"a1": "2026-01-01T00:00:00Z", "b1": "2026-01-01T00:00:00Z"}) + marks.merge_unread([_row("a1", 1), _row("b1", 1)], {}) a1, b1 = state.Conversation(ts.ROOM_A, "a1"), state.Conversation(ts.ROOM_B, "b1") kwargs = {"pepper_dot": False} assert state.next_unread(rooms, marks, current=state.PEPPER, **kwargs) == a1 assert state.next_unread(rooms, marks, current=a1, **kwargs) == b1 assert state.next_unread(rooms, marks, current=b1, **kwargs) == a1 # wraps assert state.next_unread(rooms, marks, current=a1, step=-1, **kwargs) == b1 # wraps back - assert state.any_unread(rooms, marks, current=state.PEPPER, pepper_dot=False) - marks.merge_server({"a1": "2026-02-01T00:00:00Z", "b1": "2026-02-02T00:00:00Z"}) + assert state.any_unread(rooms, marks, pepper_dot=False) + marks.merge_unread([_row("a1", 0), _row("b1", 0)], {}) assert state.next_unread(rooms, marks, current=a1, pepper_dot=True) == state.PEPPER assert state.next_unread(rooms, marks, current=a1, **kwargs) is None - assert not state.any_unread(rooms, marks, current=a1, pepper_dot=False) + assert not state.any_unread(rooms, marks, pepper_dot=False) def test_message_store_orders_by_time_then_id_and_dedupes() -> None: @@ -353,11 +380,7 @@ def test_an_answered_turn_gives_up_on_its_rows_after_the_window_and_is_not_activ # --------------------------------------------------------------------------- -# Mentions (P4, §15 C) - - -def _mention_msg(mid: str, at: str, **over) -> api_client.GroupMessage: - return api_client.group_message_from_dict(ts.message(mid, at, mentions=[ts.ME_PERSON], **over)) +# Mentions: the server's ``has_unread_mention`` flag def _unread_room(): @@ -372,151 +395,41 @@ def _unread_room(): ) ) marks = state.ReadMarks() - marks.merge_server({"t1": "2026-01-31T00:00:00+00:00", "t2": "2026-02-01T00:00:00+00:00"}) + marks.merge_unread([_row("t1", 2, mention=True), _row("t2", 1)], {}) return room, marks -def test_mentions_asks_only_about_unread_topics_and_once_per_activity() -> None: - room, marks = _unread_room() - mentions = state.Mentions() - due = mentions.due([room], marks, open_topic_id=None) - assert [(r, t.topic_id) for r, t in due] == [(ts.ROOM_A, "t1")] # t2 is read - assert mentions.due([room], marks, open_topic_id="t1") == [] # on screen, so no fetch - mentions.record(room.topics[0], [], you_id=ts.ME_PERSON, mark=marks.marks["t1"]) - assert mentions.due([room], marks, open_topic_id=None) == [] - # New activity on the same topic is asked about again. - (newer,) = _rooms(ts.room(ts.ROOM_A, "A", [ts.topic("t1", "One", "2026-02-02T00:00:00Z")])) - assert len(mentions.due([newer], marks, open_topic_id=None)) == 1 - - -def test_mentions_counts_only_unread_rows_from_others_that_name_you() -> None: - room, marks = _unread_room() - topic = room.topics[0] - mark = marks.marks["t1"] - mentions = state.Mentions() - after, before = "2026-02-01T00:00:00+00:00", "2026-01-30T00:00:00+00:00" - ignored = [ - _mention_msg("old", before), # already read - _mention_msg("mine", after, is_you=True), # your own message - _mention_msg("gone", after, is_deleted=True), - _msg("plain", after), # no mentions - api_client.group_message_from_dict(ts.message("other", after, mentions=["someone-else"])), - ] - assert mentions.record(topic, ignored, you_id=ts.ME_PERSON, mark=mark) is False - assert not mentions.topic_mentioned(topic, marks, open_topic_id=None) - assert mentions.record( - topic, [*ignored, _mention_msg("hit", after)], you_id=ts.ME_PERSON, mark=mark - ) - assert mentions.topic_mentioned(topic, marks, open_topic_id=None) - assert mentions.room_mentioned(room, marks, open_topic_id=None) - # Already known: not "newly" mentioned again (the bell rings once). - assert ( - mentions.record(topic, [_mention_msg("hit", after)], you_id=ts.ME_PERSON, mark=mark) - is False - ) - assert mentions.topic_mentioned(topic, marks, open_topic_id=None) - - -def test_a_mention_stops_showing_once_the_topic_is_read_or_open() -> None: - room, marks = _unread_room() - topic = room.topics[0] - mentions = state.Mentions() - mentions.record( - topic, [_mention_msg("hit", "2026-02-01T00:00:00+00:00")], you_id=ts.ME_PERSON, mark=None - ) - assert mentions.topic_mentioned(topic, marks, open_topic_id=None) - assert not mentions.topic_mentioned(topic, marks, open_topic_id="t1") - marks.merge("t1", state.parse_ts("2026-02-01T00:00:00+00:00")) - assert not mentions.topic_mentioned(topic, marks, open_topic_id=None) - assert not state.any_mention([room], marks, mentions, current=state.PEPPER) - - -def test_mentions_without_an_identity_never_match_and_skip_stops_asking() -> None: - room, marks = _unread_room() - topic = room.topics[0] - mentions = state.Mentions() - assert ( - mentions.record( - topic, [_mention_msg("hit", "2026-02-01T00:00:00Z")], you_id=None, mark=None - ) - is False - ) - mentions.hits["t1"] = state.parse_ts("2026-02-01T00:00:00Z") - mentions.skip(topic) - assert "t1" not in mentions.hits - assert mentions.due([room], marks, open_topic_id=None) == [] - - -def test_a_mention_after_the_topic_was_read_is_new_again_and_an_old_hit_retires() -> None: +def test_a_mention_shows_only_while_the_topic_is_unread() -> None: room, marks = _unread_room() - topic = room.topics[0] - mentions = state.Mentions() - first = "2026-02-01T00:00:00+00:00" - assert mentions.record(topic, [_mention_msg("hit", first)], you_id=ts.ME_PERSON, mark=None) - # The topic is read past the mention; the cached hit no longer shows. - marks.merge("t1", state.parse_ts(first)) - (later,) = _rooms(ts.room(ts.ROOM_A, "A", [ts.topic("t1", "One", "2026-02-03T00:00:00Z")])) - assert not mentions.topic_mentioned(later.topics[0], marks, open_topic_id=None) - # A second mention after that read is new (the bell rings again). - second = _mention_msg("hit2", "2026-02-03T00:00:00+00:00") - mark = marks.marks["t1"] - assert mentions.record(later.topics[0], [second], you_id=ts.ME_PERSON, mark=mark) is True - assert mentions.topic_mentioned(later.topics[0], marks, open_topic_id=None) - - -def test_a_topic_whose_ask_failed_goes_behind_the_other_due_topics() -> None: - (room,) = _rooms( - ts.room( - ts.ROOM_A, - "A", - [ - ts.topic("t1", "One", "2026-02-02T00:00:00Z"), - ts.topic("t2", "Two", "2026-02-01T00:00:00Z"), - ], - ) - ) - marks = state.ReadMarks() - marks.merge_server({"t1": "2026-01-01T00:00:00Z", "t2": "2026-01-01T00:00:00Z"}) - mentions = state.Mentions() - - def order() -> list[str]: - return [t.topic_id for _, t in mentions.due([room], marks, open_topic_id=None)] - - assert order() == ["t1", "t2"] - mentions.defer(room.topics[0]) - assert order() == ["t2", "t1"] - mentions.defer(room.topics[1]) - assert order() == ["t1", "t2"] # the most recently failed goes last - mentions.record(room.topics[0], [], you_id=ts.ME_PERSON, mark=None) - assert "t1" not in mentions.deferred - assert order() == ["t2"] + t1, t2 = room.topics + assert marks.is_mentioned(t1) and not marks.is_mentioned(t2) + assert marks.room_mentioned(room) + assert state.any_mention([room], marks) + assert marks.mentioned_topics() == {"t1"} + marks.merge_unread([_row("t1", 0, mention=True)], {}) # a flag on a read topic is not shown + assert not marks.is_mentioned(t1) + assert not state.any_mention([room], marks) def test_sidebar_rows_carry_the_mention_on_topic_and_collapsed_room() -> None: room, marks = _unread_room() - mentions = state.Mentions() - mentions.record( - room.topics[0], - [_mention_msg("hit", "2026-02-01T00:00:00+00:00")], - you_id=ts.ME_PERSON, - mark=None, - ) for manual, keys in ( ({}, {"room:" + ts.ROOM_A}), ({ts.ROOM_A: True}, {"room:" + ts.ROOM_A, "topic:t1"}), ): rows = state.build_sidebar( + [room], marks, current=state.PEPPER, manual=manual, show_all=set(), pepper_dot=False + ) + assert {r.key for r in rows if r.mention} == keys + assert { + r.key + for r in state.build_sidebar( [room], marks, current=state.PEPPER, - manual=manual, + manual={ts.ROOM_A: True}, show_all=set(), pepper_dot=False, - mentions=mentions, ) - assert {r.key for r in rows if r.mention} == keys - plain_rows = state.build_sidebar( - [room], marks, current=state.PEPPER, manual={}, show_all=set(), pepper_dot=False - ) - assert not any(r.mention for r in plain_rows) - assert any(r.unread for r in plain_rows) + if r.unread and r.kind == "topic" + } == {"topic:t1", "topic:t2"} diff --git a/tests/test_tui_state_parity.py b/tests/test_tui_state_parity.py index 4167e1d..d0f54fb 100644 --- a/tests/test_tui_state_parity.py +++ b/tests/test_tui_state_parity.py @@ -37,8 +37,7 @@ def _sidebar( now: datetime | None = None, ) -> list[state.SidebarRow]: if marks is None: - marks = state.ReadMarks() - marks.seed_first_sight(rooms) # a fresh install reads everything as read + marks = state.ReadMarks() # the server has said nothing is unread return state.build_sidebar( rooms, marks, @@ -73,7 +72,7 @@ def test_stale_rooms_fold_unless_open_or_unread_and_no_clock_means_no_fold() -> assert not any(r.kind == "inactive" for r in opened) assert not any(r.kind == "inactive" for r in _sidebar(rooms)) marks = state.ReadMarks() - marks.merge_server({"a1": "2026-09-30T00:00:00Z", "b1": "2026-08-01T00:00:00Z"}) + marks.merge_unread([api_client.GroupUnreadRow("b1", 2)], {}) unread = _sidebar(rooms, now=NOW, marks=marks) assert "Stale" in _labels(unread, "room") diff --git a/tests/test_tui_unread.py b/tests/test_tui_unread.py new file mode 100644 index 0000000..48e7c64 --- /dev/null +++ b/tests/test_tui_unread.py @@ -0,0 +1,175 @@ +"""Unread in the terminal UI is the server's answer (read-state-sync phase 5c). + +The fake server computes ``unread`` rows the way groups does (floor, your own messages, +tombstones); the client shows them and never decides on its own what is unread. +""" + +from __future__ import annotations + +import threading + +import pytest + +from pepper_cli.tui import app as tui_app +from pepper_cli.tui import state +from tests import tui_support as ts +from tests.tui_support import until + +NEW = "2026-09-16T18:00:00+00:00" + + +@pytest.fixture(autouse=True) +def _fast_clock(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(state, "ACTIVE_INTERVAL", 0.05) + monkeypatch.setattr(state, "COOLING_INTERVAL", 0.05) + monkeypatch.setattr(state, "IDLE_INTERVAL", 0.05) + monkeypatch.setattr(state, "RAIL_INTERVAL", 0.05) + monkeypatch.setattr(state, "DM_INTERVAL", 0.05) + monkeypatch.setattr(tui_app, "utcnow", lambda: ts.NOW) + + +def _flags(app: tui_app.PepperApp) -> dict[str, tuple[bool, bool]]: + """``topic_id -> (unread, mention)`` for every topic the sidebar shows.""" + rows = state.build_sidebar( + app.rooms, + app.marks, + current=state.PEPPER, + manual={r.group_id: True for r in app.rooms}, + show_all={r.group_id for r in app.rooms}, + pepper_dot=False, + ) + return {r.topic_id: (r.unread, r.mention) for r in rows if r.topic_id and r.kind == "topic"} + + +async def test_a_fresh_profile_shows_what_the_server_says_is_unread() -> None: + """No local marks and no seed: the markers are the server's rows, not "read as of now".""" + server = ts.standard_server() + server.marks = {} # a fresh profile: the server holds no cursor for anything + server.floor = "2026-09-16T17:00:00+00:00" + server.messages[ts.TOPIC_A1] = [ + ts.message("old", "2026-09-16T16:00:00+00:00", "before the floor"), + ts.message("n1", "2026-09-16T17:10:00+00:00", "after the floor"), + ] + server.messages[ts.TOPIC_B1] = [ + ts.message("b1", "2026-09-16T17:20:00+00:00", "ping", mentions=[ts.ME_PERSON]), + ts.message("b2", "2026-09-16T17:21:00+00:00", "pong"), + ] + server.messages[ts.TOPIC_A2] = [ts.message("a2", "2026-09-16T16:00:00+00:00", "old")] + app = tui_app.PepperApp(server.client()) + async with app.run_test(size=(120, 40)) as pilot: + await until(pilot, lambda: len(app.rooms) == 2 and bool(app.marks.unread)) + assert _flags(app) == { + ts.TOPIC_A1: (True, False), + ts.TOPIC_A2: (False, False), + ts.TOPIC_B1: (True, True), + } + assert app.marks.unread[ts.TOPIC_B1].unread_count == 2 + assert app.title == "@ Pepper" + # Nothing was written back to the server on the way: first sight never PUTs. + assert server.puts == [] + + +async def test_a_server_without_unread_rows_shows_nothing_unread() -> None: + server = ts.standard_server() + server.marks = {} + server.omit_unread = True # a server that predates phase 5b + app = tui_app.PepperApp(server.client()) + async with app.run_test(size=(120, 40)) as pilot: + await until(pilot, lambda: len(app.rooms) == 2) + await pilot.pause(0.2) + assert not any(unread for unread, _ in _flags(app).values()) + assert app.title == "Pepper" + + +async def test_your_own_newest_message_does_not_badge_its_topic() -> None: + """Activity later than the cursor is no longer the rule: the server leaves your own + messages out of the count, so a topic whose newest message is yours stays read.""" + server = ts.standard_server() + mine = ts.message("mine", NEW, "mine", is_you=True, author_person_id=ts.ME_PERSON) + server.messages[ts.TOPIC_B1].append(mine) + server.rooms[1]["topics"][0]["last_activity_at"] = NEW # past the mark the server holds + server.bump() + app = tui_app.PepperApp(server.client()) + async with app.run_test(size=(120, 40)) as pilot: + await until(pilot, lambda: len(app.rooms) == 2 and bool(app.marks.unread)) + await pilot.pause(0.3) + assert _flags(app)[ts.TOPIC_B1] == (False, False) + assert app.marks.unread[ts.TOPIC_B1].unread_count == 0 + assert app.title == "Pepper" + # Someone else's reply after it is unread. + server.messages[ts.TOPIC_B1].append(ts.message("theirs", "2026-09-16T18:05:00+00:00", "hi")) + server.rooms[1]["topics"][0]["last_activity_at"] = "2026-09-16T18:05:00+00:00" + server.bump() + await until(pilot, lambda: _flags(app)[ts.TOPIC_B1][0]) + + +async def test_sending_a_message_does_not_badge_its_own_topic() -> None: + server = ts.standard_server() + app = tui_app.PepperApp(server.client()) + async with app.run_test(size=(120, 40)) as pilot: + await until(pilot, lambda: len(app.rooms) == 2) + app.open_conversation(state.Conversation(ts.ROOM_B, ts.TOPIC_B1)) + await until(pilot, lambda: app.loaded_topic) + app.composer.focus() + app.composer.insert("hello") + await pilot.press("enter") + await until(pilot, lambda: bool(server.posts)) + await pilot.pause(0.4) + app.open_conversation(state.PEPPER) # leave: the open topic no longer hides a marker + await pilot.pause(0.4) + assert _flags(app)[ts.TOPIC_B1] == (False, False) + + +async def test_a_read_that_passes_the_activity_clears_the_row_and_a_partial_one_asks_again() -> ( + None +): + server = ts.standard_server() + server.messages[ts.TOPIC_B1] = [ + ts.message("b1", "2026-09-16T17:00:00+00:00", "one"), + ts.message("b2", "2026-09-16T17:30:00+00:00", "two"), + ] + server.marks[ts.TOPIC_B1] = "2026-09-15T10:00:00+00:00" + server.rooms[1]["topics"][0]["last_activity_at"] = "2026-09-16T17:30:00+00:00" + server.bump() + first, second, hold = threading.Event(), threading.Event(), threading.Event() + partial = ts.read_advanced(ts.TOPIC_B1, "2026-09-16T17:00:00+00:00", 2) + full = ts.read_advanced(ts.TOPIC_B1, "2026-09-16T17:30:00+00:00", 3) + server.streams = [ + ts.StreamScript( + frames=[ts.CURSOR_HEAD, partial, full], gates={1: first, 2: second}, hold=hold + ) + ] + app = tui_app.PepperApp(server.client()) + try: + async with app.run_test(size=(120, 40)) as pilot: + await until(pilot, lambda: app._stream_healthy and bool(app.marks.unread)) + assert app.marks.unread[ts.TOPIC_B1].unread_count == 2 + # The partial read moved the cursor to b1; what is left is the server's to say. + server.marks[ts.TOPIC_B1] = "2026-09-16T17:00:00+00:00" + first.set() + await until(pilot, lambda: app.marks.unread[ts.TOPIC_B1].unread_count == 1) + # The full read clears it, from the frame alone. + second.set() + await until(pilot, lambda: not _flags(app)[ts.TOPIC_B1][0]) + assert app.marks.unread[ts.TOPIC_B1].unread_count == 0 + finally: + hold.set() + + +async def test_viewing_a_topic_to_its_end_clears_its_marker_before_the_put_answers() -> None: + server = ts.standard_server() + server.messages[ts.TOPIC_B1].append(ts.message("m-new", NEW, "fresh")) + server.rooms[1]["topics"][0]["last_activity_at"] = NEW + server.bump() + gate = threading.Event() + server.gates[f"PUT /api/v1/groups/{ts.ROOM_B}/topics/{ts.TOPIC_B1}/read"] = gate + app = tui_app.PepperApp(server.client()) + try: + async with app.run_test(size=(120, 40)) as pilot: + await until(pilot, lambda: _flags(app).get(ts.TOPIC_B1, (False,))[0]) + app.open_conversation(state.Conversation(ts.ROOM_B, ts.TOPIC_B1)) + await until(pilot, lambda: "fresh" in ts.body_text(app)) + await until(pilot, lambda: not _flags(app)[ts.TOPIC_B1][0]) + assert server.puts == [] # the server has not heard yet + finally: + gate.set() diff --git a/tests/tui_support.py b/tests/tui_support.py index 3761867..923c6a5 100644 --- a/tests/tui_support.py +++ b/tests/tui_support.py @@ -211,6 +211,12 @@ class FakeServer: rooms: list[dict[str, Any]] = field(default_factory=list) messages: dict[str, list[dict[str, Any]]] = field(default_factory=dict) marks: dict[str, str] = field(default_factory=dict) + #: The server's floor: a topic with no mark counts only messages newer than this. + floor: str = "2026-01-01T00:00:00+00:00" + #: ``topic_id -> unread row`` the server answers instead of computing one (a scripted rule). + unread_override: dict[str, dict[str, Any]] = field(default_factory=dict) + #: Leave the ``unread`` key out of ``GET .../read`` (a server that predates phase 5b). + omit_unread: bool = False history: list[dict[str, Any]] = field(default_factory=list) enabled: bool = True degraded: bool = False @@ -285,6 +291,34 @@ def handler(self, request: httpx.Request) -> httpx.Response: raise exc return response + def _unread_rows(self, group_id: str) -> list[dict[str, Any]]: + """The room's ``unread`` rows as the server computes them (read-state-sync §6): messages + after the later of the mark and the floor, not your own, not tombstones.""" + room_row = next(r for r in self.rooms if r["group_id"] == group_id) + rows: list[dict[str, Any]] = [] + for t in room_row["topics"]: + tid = t["topic_id"] + if tid in self.unread_override: + rows.append({"topic_id": tid, "floor": self.floor, **self.unread_override[tid]}) + continue + cutoff = datetime.fromisoformat(max(filter(None, [self.marks.get(tid), self.floor]))) + counted = [ + m + for m in self.messages.get(tid, []) + if not m["is_you"] + and not m["is_deleted"] + and datetime.fromisoformat(m["created_at"]) > cutoff + ] + rows.append( + { + "topic_id": tid, + "floor": self.floor, + "unread_count": len(counted), + "has_unread_mention": any(ME_PERSON in m.get("mentions", []) for m in counted), + } + ) + return rows + def _next_at(self) -> str: """A created_at later than every fixture row (the server's clock moves on).""" self.clock += 1 @@ -502,7 +536,10 @@ def _answer(self, request: httpx.Request, path: str, query: dict[str, str]) -> h if parts[:3] == ["api", "v1", "groups"] and len(parts) >= 5: if parts[4] == "read" and request.method == "GET": marks = [{"topic_id": t, "read_through": v} for t, v in self.marks.items()] - return httpx.Response(200, json={"marks": marks}) + body: dict[str, Any] = {"marks": marks} + if not self.omit_unread: + body["unread"] = self._unread_rows(parts[3]) + return httpx.Response(200, json=body) if len(parts) == 7 and parts[4] == "topics" and parts[6] == "read": through = json.loads(request.content)["read_through"] self.puts.append((parts[5], through)) From c78c4e04c6823d3a487efdc7aa0c730e524ccafb Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Tue, 27 Oct 2026 23:01:52 +0000 Subject: [PATCH 2/5] docs(api): MIRROR the /read row and state dataclasses to groups::read_marks Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Lk5Upqk2oCRiV3ZENbLyLL --- src/pepper_cli/api_client.py | 4 ++++ tests/test_api_contract.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pepper_cli/api_client.py b/src/pepper_cli/api_client.py index efcbe3f..6dde324 100644 --- a/src/pepper_cli/api_client.py +++ b/src/pepper_cli/api_client.py @@ -1236,6 +1236,8 @@ class GroupSummary: topics: list[GroupTopicSummary] = field(default_factory=list) +# MIRROR: src/pepper/api/groups.py::read_marks +# (route handler; untyped dict response, no snapshot schema) @dataclass(frozen=True) class GroupUnreadRow: """One topic's unread answer, as the server computed it (read-state-sync §6). @@ -1250,6 +1252,8 @@ class GroupUnreadRow: floor: str | None = None +# MIRROR: src/pepper/api/groups.py::read_marks +# (route handler; untyped dict response, no snapshot schema) @dataclass(frozen=True) class GroupReadState: """``GET /api/v1/groups/{gid}/read``: read marks plus the room's unread rows.""" diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index f8f9cad..3b9d0d3 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -97,7 +97,7 @@ class NotCheckable: route=("GET", "/api/v1/groups/{group_id}/roster"), ), ("api_client.py", "src/pepper/api/groups.py::read_marks"): NotCheckable( - "Route handler behind ``get_group_read_state`` " + "Route handler behind GroupUnreadRow and GroupReadState (via ``get_group_read_state``) " "(``{marks: [{topic_id, read_through}], unread: [{topic_id, unread_count, " "has_unread_mention, floor}]}``). " f"{_UNTYPED}", From a0f94c820109600b68cef847a741cfede082705c Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Tue, 27 Oct 2026 23:05:54 +0000 Subject: [PATCH 3/5] fix(tui): settle an unread row only on a cursor newer than its answer The rail reads /groups before /read, so a message landing between the two left the room's activity at the cursor while the answer counted it; settle then zeroed the fresh row. A row now remembers the cursor the server counted from, and only a cursor that moved after it can clear the row. The same rule replaces merge_unread's stale-cursor skip, which dropped a fresh row (and a message newer than the local read) until the room moved again: every row is kept, and the newer local cursor settles it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Lk5Upqk2oCRiV3ZENbLyLL --- planning/terminal-ui.md | 5 ++++- src/pepper_cli/tui/state.py | 31 ++++++++++++++++++----------- tests/test_tui_state.py | 39 ++++++++++++++++++++++++++++++++----- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/planning/terminal-ui.md b/planning/terminal-ui.md index 9ebf81a..e4ed70d 100644 --- a/planning/terminal-ui.md +++ b/planning/terminal-ui.md @@ -203,7 +203,10 @@ the person's cursor and their floor, not their own, not tombstones, through the back (first sight never PUTs). - The one local derivation can only clear: a cursor (our own mark, or `read_state.advanced`) at or after a topic's newest activity leaves nothing unread under any rule, so the cached row is - dropped until the server's next answer. A cursor short of it clears nothing. + dropped until the server's next answer. A cursor short of it clears nothing. Only a cursor that + moved after the server counted the row can clear it: the rail reads `/groups` before `/read`, + so the activity it compares against can be older than the answer. For the same reason every + row is kept, including one counted from a cursor older than ours; the newer cursor settles it. - **Read = the newest row has been on screen** (read-state-sync §5.2): with the terminal focused and the thread at its end, the newest row's `created_at` is published as `PUT …/topics/{tid}/read` (the server's own string, never a device clock). **Scrolled up, nothing is marked**, and the open diff --git a/src/pepper_cli/tui/state.py b/src/pepper_cli/tui/state.py index 93fd7f5..8c2091e 100644 --- a/src/pepper_cli/tui/state.py +++ b/src/pepper_cli/tui/state.py @@ -121,8 +121,9 @@ class ReadMarks: The server decides what is unread (its floor, your own messages, tombstones, a carried topic's reach) and answers it as ``unread`` rows; this holds the latest answer per topic and the read cursors that came with it. The one thing the client adds is that a cursor can - only ever clear: a mark at or after a topic's newest activity leaves nothing in it unread - under any rule, so :meth:`settle` drops the cached row until the server's next answer. + only ever clear: a mark that moved after the server counted, and is at or after the topic's + newest activity, leaves nothing in it unread under any rule, so :meth:`settle` drops the + cached row until the server's next answer. Nothing here ever *raises* a count, and a topic the server has no row for is not unread. Mark merges are "take the max", so a late answer cannot walk a mark backwards. """ @@ -130,6 +131,8 @@ class ReadMarks: marks: dict[str, datetime] = field(default_factory=dict) #: ``topic_id -> the server's unread row`` (zero-count rows are kept: they say "read"). unread: dict[str, GroupUnreadRow] = field(default_factory=dict) + #: ``topic_id -> the cursor the server counted that row from`` (``None``: it had none). + counted_from: dict[str, datetime | None] = field(default_factory=dict) def merge(self, topic_id: str, at: datetime | None) -> bool: """Raise the mark for ``topic_id`` to ``at`` if that is newer. True when it moved.""" @@ -148,16 +151,16 @@ def merge_server(self, server_marks: dict[str, str]) -> None: def merge_unread(self, rows: Iterable[GroupUnreadRow], marks: Mapping[str, str]) -> None: """Take the server's unread rows (and the cursors answered with them). - A row older than a cursor already held here (an answer that crossed a read on this - device or another) is stale for that topic and is skipped; the next answer covers it. + Every row is kept, even one counted from a cursor older than one held here (an answer + that crossed a read on this device or another): it may count a message newer than that + read, and dropping it would hide the message until the room moved again. Each row + remembers the cursor it was counted from, and :meth:`settle` applies only a cursor that + moved after it. """ self.merge_server(dict(marks)) for row in rows: - held = self.marks.get(row.topic_id) - answered = parse_ts(marks.get(row.topic_id)) - if held is not None and (answered is None or answered < held): - continue self.unread[row.topic_id] = row + self.counted_from[row.topic_id] = parse_ts(marks.get(row.topic_id)) def merge_advanced(self, payload: object) -> str | None: """Fold a ``read_state.advanced`` event payload (``{topic_id, group_id, last_read_at}``) @@ -178,8 +181,11 @@ def merge_advanced(self, payload: object) -> str | None: def settle(self, rooms: Iterable[GroupSummary]) -> bool: """Clear the cached row of every topic whose mark has passed its newest activity. - True when a row was cleared. A mark short of the activity (a partial read) clears - nothing: the count is the server's, and the caller asks it again. + Only a mark newer than the cursor the row was counted from can clear it: ``rooms`` is + read before the rows (the rail asks ``/groups``, then ``/read``), so its activity can + be older than the answer, and a cursor the server already counted from says nothing + the row does not. True when a row was cleared. A mark short of the activity (a partial + read) clears nothing: the count is the server's, and the caller asks it again. """ cleared = False for room in rooms: @@ -188,7 +194,10 @@ def settle(self, rooms: Iterable[GroupSummary]) -> bool: if row is None or (row.unread_count == 0 and not row.has_unread_mention): continue mark, activity = self.marks.get(topic.topic_id), parse_ts(topic.last_activity_at) - if mark is not None and activity is not None and mark >= activity: + if mark is None or activity is None or mark < activity: + continue + since = self.counted_from.get(topic.topic_id) + if since is None or mark > since: self.unread[topic.topic_id] = replace( row, unread_count=0, has_unread_mention=False ) diff --git a/tests/test_tui_state.py b/tests/test_tui_state.py index 9e97807..ff55409 100644 --- a/tests/test_tui_state.py +++ b/tests/test_tui_state.py @@ -78,13 +78,42 @@ def test_a_cursor_past_the_activity_clears_a_row_and_a_partial_one_does_not() -> assert marks.settle([room]) is False # nothing left to clear -def test_a_stale_answer_does_not_relight_a_topic_read_since() -> None: +def test_an_answer_that_crossed_a_read_is_kept_and_settled_by_that_read() -> None: + (room,) = _rooms(ts.room(ts.ROOM_A, "A", [ts.topic("t1", "One", "2026-02-01T00:00:00Z")])) + (topic,) = room.topics + marks = state.ReadMarks() + marks.merge("t1", state.parse_ts("2026-02-01T00:00:00Z")) # read here, PUT not landed yet + marks.merge_unread([_row("t1", 2)], {"t1": "2026-01-01T00:00:00Z"}) # counted from before it + assert marks.unread["t1"].unread_count == 2 # kept: it may count a message newer than the read + assert marks.settle([room]) is True # the read covers the activity, so it clears + assert not marks.is_unread(topic) + + +def test_an_answer_that_crossed_a_read_still_shows_a_message_past_it() -> None: + (room,) = _rooms(ts.room(ts.ROOM_A, "A", [ts.topic("t1", "One", "2026-02-02T00:00:00Z")])) + (topic,) = room.topics + marks = state.ReadMarks() + marks.merge("t1", state.parse_ts("2026-02-01T00:00:00Z")) + marks.merge_unread([_row("t1", 2, mention=True)], {"t1": "2026-01-01T00:00:00Z"}) + assert marks.settle([room]) is False + assert marks.is_unread(topic) and marks.is_mentioned(topic) + + +def test_a_cursor_the_server_counted_from_does_not_settle_its_answer() -> None: + # /groups is read before /read: a message landing between the two leaves the room's + # activity at the cursor while the answer counts the new message. + (room,) = _rooms(ts.room(ts.ROOM_A, "A", [ts.topic("t1", "One", "2026-02-01T00:00:00Z")])) + (topic,) = room.topics marks = state.ReadMarks() marks.merge("t1", state.parse_ts("2026-02-01T00:00:00Z")) - marks.merge_unread([_row("t1", 2)], {"t1": "2026-01-01T00:00:00Z"}) # read before the cursor - assert "t1" not in marks.unread - marks.merge_unread([_row("t1", 1)], {"t1": "2026-02-01T00:00:00Z"}) - assert marks.unread["t1"].unread_count == 1 + marks.merge_unread([_row("t1", 1, mention=True)], {"t1": "2026-02-01T00:00:00Z"}) + assert marks.settle([room]) is False + assert marks.settle([room]) is False # nor does a later repaint against the same rooms + assert marks.is_unread(topic) and marks.is_mentioned(topic) + marks.merge_advanced({"topic_id": "t1", "last_read_at": "2026-02-01T00:00:01Z"}) + later = _rooms(ts.room(ts.ROOM_A, "A", [ts.topic("t1", "One", "2026-02-01T00:00:01Z")])) + assert marks.settle(later) is True # a cursor that moved after the answer does clear it + assert not marks.is_unread(topic) def _big_room() -> list[api_client.GroupSummary]: From 0d0816b2071e1ff5da6ffb738045335af7984791 Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Tue, 27 Oct 2026 23:05:54 +0000 Subject: [PATCH 4/5] fix(tui): ring the bell only for a mention the rail shows mentioned_topics read raw rows, so an unread mention in an archived topic rang the bell with no marker on screen. It now goes through is_mentioned. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Lk5Upqk2oCRiV3ZENbLyLL --- src/pepper_cli/tui/app.py | 4 ++-- src/pepper_cli/tui/state.py | 6 +++--- tests/test_tui_state.py | 6 +++++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/pepper_cli/tui/app.py b/src/pepper_cli/tui/app.py index 4b02d9b..f42d2c6 100644 --- a/src/pepper_cli/tui/app.py +++ b/src/pepper_cli/tui/app.py @@ -707,11 +707,11 @@ def _on_rail(self, update: poller.RailUpdate) -> None: def _apply_groups( self, groups: GroupList, server_marks: dict[str, str], unread: list[GroupUnreadRow] ) -> None: + had = self.marks.mentioned_topics(self.rooms) self.rooms = groups.groups - had = self.marks.mentioned_topics() self.marks.merge_unread(unread, server_marks) self.marks.settle(self.rooms) - if self._bell and (self.marks.mentioned_topics() - had - {self.current.topic_id}): + if self._bell and (self.marks.mentioned_topics(self.rooms) - had - {self.current.topic_id}): self.bell() def _refresh_next(self) -> None: diff --git a/src/pepper_cli/tui/state.py b/src/pepper_cli/tui/state.py index 8c2091e..465f0a4 100644 --- a/src/pepper_cli/tui/state.py +++ b/src/pepper_cli/tui/state.py @@ -221,9 +221,9 @@ def room_unread(self, room: GroupSummary) -> bool: def room_mentioned(self, room: GroupSummary) -> bool: return any(self.is_mentioned(t) for t in room.topics) - def mentioned_topics(self) -> set[str]: - """Topic ids whose unread rows carry a mention (for spotting a new one).""" - return {t for t, row in self.unread.items() if row.has_unread_mention} + def mentioned_topics(self, rooms: Iterable[GroupSummary]) -> set[str]: + """Ids of the topics shown as mentioned (for spotting a new one; archived ones are not).""" + return {t.topic_id for room in rooms for t in room.topics if self.is_mentioned(t)} def topics_by_recency(room: GroupSummary) -> list[GroupTopicSummary]: diff --git a/tests/test_tui_state.py b/tests/test_tui_state.py index ff55409..af00f2b 100644 --- a/tests/test_tui_state.py +++ b/tests/test_tui_state.py @@ -434,7 +434,11 @@ def test_a_mention_shows_only_while_the_topic_is_unread() -> None: assert marks.is_mentioned(t1) and not marks.is_mentioned(t2) assert marks.room_mentioned(room) assert state.any_mention([room], marks) - assert marks.mentioned_topics() == {"t1"} + assert marks.mentioned_topics([room]) == {"t1"} + (archived,) = _rooms( + ts.room(ts.ROOM_A, "A", [ts.topic("t1", "One", "2026-02-01T00:00:00Z", archived_at="x")]) + ) + assert marks.mentioned_topics([archived]) == set() # an archived topic shows no mention marks.merge_unread([_row("t1", 0, mention=True)], {}) # a flag on a read topic is not shown assert not marks.is_mentioned(t1) assert not state.any_mention([room], marks) From 469f8699a61fbff53381e23882d4a239a97075e3 Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Tue, 27 Oct 2026 23:12:14 +0000 Subject: [PATCH 5/5] refactor(tui): simplify pass on the unread cache The fake server's unread rule parses timestamps with parse_ts like the client does; merge_server takes a Mapping so merge_unread passes marks through; the bell's before-snapshot is skipped when the bell is off. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Lk5Upqk2oCRiV3ZENbLyLL --- src/pepper_cli/tui/app.py | 2 +- src/pepper_cli/tui/state.py | 4 ++-- tests/tui_support.py | 7 +++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/pepper_cli/tui/app.py b/src/pepper_cli/tui/app.py index f42d2c6..640f811 100644 --- a/src/pepper_cli/tui/app.py +++ b/src/pepper_cli/tui/app.py @@ -707,7 +707,7 @@ def _on_rail(self, update: poller.RailUpdate) -> None: def _apply_groups( self, groups: GroupList, server_marks: dict[str, str], unread: list[GroupUnreadRow] ) -> None: - had = self.marks.mentioned_topics(self.rooms) + had = self.marks.mentioned_topics(self.rooms) if self._bell else set() self.rooms = groups.groups self.marks.merge_unread(unread, server_marks) self.marks.settle(self.rooms) diff --git a/src/pepper_cli/tui/state.py b/src/pepper_cli/tui/state.py index 465f0a4..9209cfc 100644 --- a/src/pepper_cli/tui/state.py +++ b/src/pepper_cli/tui/state.py @@ -144,7 +144,7 @@ def merge(self, topic_id: str, at: datetime | None) -> bool: self.marks[topic_id] = at return True - def merge_server(self, server_marks: dict[str, str]) -> None: + def merge_server(self, server_marks: Mapping[str, str]) -> None: for topic_id, raw in server_marks.items(): self.merge(topic_id, parse_ts(raw)) @@ -157,7 +157,7 @@ def merge_unread(self, rows: Iterable[GroupUnreadRow], marks: Mapping[str, str]) remembers the cursor it was counted from, and :meth:`settle` applies only a cursor that moved after it. """ - self.merge_server(dict(marks)) + self.merge_server(marks) for row in rows: self.unread[row.topic_id] = row self.counted_from[row.topic_id] = parse_ts(marks.get(row.topic_id)) diff --git a/tests/tui_support.py b/tests/tui_support.py index 923c6a5..8b32018 100644 --- a/tests/tui_support.py +++ b/tests/tui_support.py @@ -301,13 +301,16 @@ def _unread_rows(self, group_id: str) -> list[dict[str, Any]]: if tid in self.unread_override: rows.append({"topic_id": tid, "floor": self.floor, **self.unread_override[tid]}) continue - cutoff = datetime.fromisoformat(max(filter(None, [self.marks.get(tid), self.floor]))) + from pepper_cli.tui.state import parse_ts + + bounds = [parse_ts(self.marks.get(tid)), parse_ts(self.floor)] + cutoff = max((b for b in bounds if b is not None), default=None) counted = [ m for m in self.messages.get(tid, []) if not m["is_you"] and not m["is_deleted"] - and datetime.fromisoformat(m["created_at"]) > cutoff + and (cutoff is None or (parse_ts(m["created_at"]) or cutoff) > cutoff) ] rows.append( {