Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 11 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,9 +421,11 @@ or `no`). `pepper <message>` 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:

Expand Down Expand Up @@ -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
Expand Down
46 changes: 29 additions & 17 deletions planning/terminal-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -188,17 +191,26 @@ 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. 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
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
Expand All @@ -211,9 +223,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)

Expand Down Expand Up @@ -313,7 +324,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
```
Expand Down Expand Up @@ -347,8 +358,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
Expand Down Expand Up @@ -431,7 +442,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
Expand Down
56 changes: 52 additions & 4 deletions src/pepper_cli/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1236,6 +1236,45 @@ 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:
Comment thread
allenhutchison marked this conversation as resolved.
Resolved
"""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


# 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."""

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)
Expand Down Expand Up @@ -3213,17 +3252,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
Expand Down
Loading
Loading