Skip to content

feat(tui): consume unread state — badge, glyph, inbox, focus-leave (#185 PR2/5)#189

Merged
gerchowl merged 6 commits intodevfrom
land/185-pr2-tui-unread
Apr 28, 2026
Merged

feat(tui): consume unread state — badge, glyph, inbox, focus-leave (#185 PR2/5)#189
gerchowl merged 6 commits intodevfrom
land/185-pr2-tui-unread

Conversation

@gerchowl
Copy link
Copy Markdown
Contributor

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:

  • Status bar shows `[N new]` whenever there's anything unread for the current reader
  • Papers list rows get a `●` glyph for any paper with unread annotations
  • Reader two-pane right side shows `[unread]` after every unread root + reply line
  • `U` opens the inbox overlay listing every unread item grouped by paper, thread-major; Enter jumps to the paper reader focused on the chosen thread
  • Focus-leave + overlay-close call `mark_thread_seen` so the badge/glyphs/markers clear without a manual action — the explicit "currently never called" gap from the issue

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

  1. `f9eefd4` DB `count_unread` / `papers_with_unread` + DataStore wrappers
  2. `e2ac5bb` status-bar `[N new]` badge
  3. `357bdf9` per-row `●` glyph + per-thread `[unread]` markers
  4. `c4af121` `mark_thread_seen` on focus-leave + overlay-close
  5. `9eddd93` `views/inbox.rs` + `U` keybind + tape
  6. `14a9c82` review-driven test/doc fixes

Test plan

  • 83/83 `cargo test -p scitadel-tui --lib` pass (up from 67 pre-PR)
  • DB-side: `count_unread` matches `list_unread().len()`, `papers_with_unread` distincts correctly, soft-delete excluded
  • DataStore: round-trip with mark_thread_seen, cross-reader independence, paper-scoped vs all
  • App: focus-arrive doesn't mark seen (no prev), focus-leave-to-None marks prev, idempotent same-root, reply-index resolves to root, overlay-close via `handle_key` clears badge
  • Inbox: groups by paper, thread-major order, orphan reply uses parent_id, jump_target on header → None / on reply → root, step_selection skips headers, snap on cursor-clamp
  • App E2E: `U` toggles inbox open/close, Enter jumps to PaperDetail-reader with focus on root
  • `cargo clippy -p scitadel-tui --tests -- -D warnings` clean
  • `cargo fmt --check` clean
  • New `tests/vhs/tui-inbox.tape` exercises badge → ● glyph → U → Enter → focus-leave-clears-badge; assigned to the `tui-papers` shard with shard-coverage gate updated
  • CI validates the new tape renders distinct screenshots

Out of scope (follow-ups in this issue)

  • MCP push notifications (PR3)
  • `create_paper_note` (PR4)
  • Selection-fidelity fix in `publish_tui_state` reader mode (PR5)

Refs #185.

…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.
@gerchowl gerchowl merged commit f04af29 into dev Apr 28, 2026
19 checks passed
@gerchowl gerchowl deleted the land/185-pr2-tui-unread branch April 28, 2026 18:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant