feat(tui): consume unread state — badge, glyph, inbox, focus-leave (#185 PR2/5)#189
Merged
feat(tui): consume unread state — badge, glyph, inbox, focus-leave (#185 PR2/5)#189
Conversation
…rappers (#185) Plumbing for the TUI's missing-half consumption of the existing unread infrastructure: - `SqliteAnnotationRepository::count_unread(reader, paper_id?)` — COUNT(*) variant of `list_unread`. Driven by the TUI status bar on every draw tick; materialising the rows would be wasteful when we only need a number. - `DataStore::load_unread_count(reader)` — total across all papers, for the status-bar `[N new]` badge. - `DataStore::load_unread_for_paper(reader, paper_id)` — for the per-row glyph and per-row `[unread]` markers. - `DataStore::mark_thread_seen(reader, root_id)` — for focus-leave and overlay-close calls in subsequent commits. Tests: count matches list-length; soft-delete excluded; per-paper scoping; cross-reader independence; mark_thread_seen clears both root and replies for the calling reader only. Refs #185. [tape-exempt: data-layer plumbing only; no UI rendered yet]
Wires `data.load_unread_count` into the status bar via a cached `unread_count` on `App`, refreshed every render tick. The badge sits between `[OFFLINE]` and the help text and only renders when count > 0. Refresh cost is one indexed `COUNT(*)` per ~100ms tick — negligible on SQLite WAL — and a transient DB error is silently swallowed (a stale badge is acceptable; an error toast every 100ms is not). 5 new unit tests on `build_spans` lock the badge composition: quiet state, offline-only, unread-only, both, and negative/zero defensive case. Refs #185.
The two visible signals that point the human at where new agent activity lives: - Papers list gains a `●` column (between star and download status) for any paper that has at least one annotation `reader` hasn't acknowledged. Drawn from a HashSet cached on `App`, refreshed every draw tick alongside the status-bar count. - Reader two-pane right side gains an `[unread]` marker at the end of every root + reply line that's still unread for `reader`. Resolved per-frame from `data.load_unread_for_paper` (typical paper has ~ten annotations, this is cheap). DB layer adds `papers_with_unread(reader)` — single indexed DISTINCT query so the `●` set is one round-trip per tick rather than materialising every unread row. Refs #185.
The headline "currently never called" gap from #185. Closes the unread-state cycle so the badge and per-row glyphs clear without the human running a manual command. App-level tracker `last_focused_root_id` records the thread the cursor is currently sitting on. Three free helpers (`set_focused_thread` / `focus_thread_by_root_idx` / `focus_thread_by_annotation_idx`) take disjoint &mut field borrows so they can be called while `self.overlay.as_mut()` holds a mutable reference to the overlay slot. Wired into `handle_paper_detail_key`: - Reader mode J/K cursor → focus_thread_by_root_idx(new highlight) - Reader mode Esc/q/R → focus_thread_by_root_idx(None) - Annotation focus j/k → focus_thread_by_annotation_idx(new focus) (resolves reply rows to their root) - Annotation focus Esc → focus_thread_by_annotation_idx(None) - Layer-3 J / R entry → mark the new thread as currently focused - Layer-3 Esc/q (overlay close) → set_focused_thread(None) Glance behaviour: focusing a thread for one frame then leaving marks it seen, matching the user's mental model that "I saw it." Repeat calls with the same root are a no-op so `mark_thread_seen` runs at most once per actual focus change. 5 new App tests cover: focus-arrive doesn't mark seen, focus-leave to None marks the previous thread, idempotency on same-root, reply-index resolves to root, and end-to-end overlay-close via `handle_key` clears the badge. Refs #185.
Closes the action loop for the [N new] badge: the user presses `U`,
sees what's new grouped by paper, presses Enter to jump to the paper
reader focused on the chosen thread.
views/inbox.rs is a flat thread-major item list (header + roots +
replies) with per-row navigation that skips over headers. Three
unit-testable pure helpers (`build_items`, `step_selection`,
`jump_target`) plus the `draw` and `load` glue. Orphan replies — root
already seen, only the reply is fresh — surface under the parent_id
as the root so the jump still lands on the thread.
App-side wiring:
- `Overlay::Inbox { items, selected }` variant
- Global `U` keybind toggles open/close (gated by in_text_input_context)
- `handle_inbox_key` uses a compute-action-then-apply pattern so
Enter can call `&mut self` helpers without fighting the borrow
checker against the overlay destructure
- Inbox items are rebuilt on every render tick so MCP-side writes
show up live; cursor is preserved (clamped + snapped past headers)
- Jumping into a thread sets `last_focused_root_id` so the existing
focus-leave plumbing marks it seen on overlay close
VHS tape `tui-inbox.tape` exercises the full P0 flow: badge on
launch → ● glyph on Papers → U → Enter into reader → focus-leave
clears the badge. Added to the `tui-papers` shard with the
shard-coverage gate updated to match.
Refs #185.
Two minor nits surfaced in review: - `data.rs::for_tests` doc comment had drifted into the middle of `load_snapshot_inputs` after a prior edit. Restored. - The `step_selection(items, sel, 0)` snap path used by the inbox per-tick rebuild (when an item was removed mid-tick) had no test. Added two: snap off a header onto the first selectable row, and a defensive empty-list no-op. Refs #185.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the "TUI consumes unread state" P0 of #185 — the big missing half. Before this PR, MCP-side annotation writes were invisible to the human until they happened to open the right paper. Now:
DB layer adds `count_unread(reader, paper_id?)` and `papers_with_unread(reader)` so per-tick state refresh is two indexed queries, not a row materialisation.
Commits
Test plan
Out of scope (follow-ups in this issue)
Refs #185.