# Journal — 2026-04-21 ## 2026-04-21 — Fixed gh#81: bookmark Tab navigation breaks after Enter **Symptom**: In the TUI, pressing `B` to view bookmarks, tabbing to a bookmark, pressing Enter loads the target document — but the next Tab still behaved as if navigating the bookmark library, requiring a second Enter before Tab cycled links in the loaded doc. **Root cause**: `handleBookmarkView` in `client/cmd/demarkus-tui/main.go` set `m.links` to the bookmark URLs but did NOT reset the sibling state — `m.linkInfos`, `m.markedRendered`, `m.linkRegions`, `m.hoverIdx`. The viewport rendered the bookmark list plainly (no marker injection), so pressing Tab fell into `handleTabNavigation`'s `if m.markedRendered != ""` branch with the *previous* document's marked output, and `processMarkers` overwrote the viewport with stale content. The index set (`m.links`) and the marker map (`m.markedRendered`) referred to different documents — the bug was that desync. **Fix**: mirror what `handleFetchResult` does for a real fetch — call `links.ExtractWithPositions(body)`, build `m.links` from the resulting `LinkInfo`, then `injectLinkMarkers` + `processMarkers` on the rendered bookmark list. Also explicitly reset `m.hoverIdx = -1`, `m.markedRendered = ""`, `m.linkRegions = nil` up front so the not-ready path still clears stale state. **Why it matters / lesson**: Any place that mutates `m.links` must also refresh the paired state (`linkInfos`, `markedRendered`, `linkRegions`) in lockstep. These four fields together describe "the current document's navigable links"; if `m.links` is the bookmarks but `m.markedRendered` is the last doc, Tab renders one while navigating the other. Single-source-of-truth these as a struct or centralize the reset in a helper if more entry points appear. **Regression test**: `TestHandleBookmarkViewResetsLinkState` in `client/cmd/demarkus-tui/links_test.go` — seeds the model with stale prev-doc state, calls `handleBookmarkView`, asserts every paired field is refreshed. ### Follow-ups (same session, gh#81 fallout) **Mouse wheel scroll was dead.** The TUI's `Update` dispatched `KeyPressMsg`, `MouseClickMsg`, and `MouseMotionMsg`, but `tea.MouseWheelMsg` fell through to the default `return m, nil` — wheel events never reached `m.viewport`, which has perfectly good built-in wheel handling. Added a `MouseWheelMsg` case that forwards to `m.viewport.Update(msg)`. **Tab-after-wheel-scroll snapped back.** Once wheel scroll worked, a second problem surfaced: `handleTabNavigation` always incremented `m.linkIdx` by 1 from wherever the cursor had last been. If the user scrolled far past that link, Tab yanked the viewport back to the old position. Fix: before incrementing, check whether the current `m.linkIdx` is still inside the viewport window. If it isn't (or linkIdx is `-1`), rebase linkIdx on the first link in the visible scroll region. Tab then advances to the first link in what the user is actually looking at. Added two small model helpers — `linkVisible(idx)` and `firstVisibleLinkIdx()` — that walk `m.linkRegions` against `viewport.YOffset()`/`Height()`. **Lesson**: when the viewport's scroll state can drift independently of the "selected link" cursor, any cursor-advance operation needs to reconcile the two. `m.linkIdx` and `viewport.YOffset()` are two cursors over the same content — Tab is where they must agree. If we ever add shift-Tab, apply the symmetric rule (rebase to the *last* visible link). **Regression tests**: `TestHandleTabNavigationResumesFromScrollPosition` in `client/cmd/demarkus-tui/links_test.go` — table-driven, covers selection above viewport, below viewport, no selection, and already-visible cases. ### Follow-up: markdown didn't re-wrap on terminal resize `handleWindowSize` used to resize only the viewport dimensions and re-render just the graph view. Document and bookmark content kept the line breaks glamour had computed at the original width — so widening the terminal left huge right gutters, and narrowing it clipped lines off the right edge. The glamour renderer in `renderMarkdown` already invalidates its cached `*glamour.TermRenderer` when `wrapWidth` changes; nothing was calling it on resize. Extracted a `(*model).rewrapContent(viewportHeight)` helper that `handleWindowSize` now calls. Logic: graph view → re-render; help → static, leave alone; error → re-set error view; otherwise (document or bookmarks) → snapshot `viewport.ScrollPercent()`, re-render `m.rawBody`, re-inject link markers against `m.linkInfos`, re-process regions, and restore `YOffset` as `percent * (totalLines - viewportHeight)`. That keeps the user's approximate reading position across resize even when line count changes. **Why the helper**: three branches need to stay in sync with `handleFetchResult`'s render pipeline (markedRendered → processMarkers → linkRegions). Inlining would have re-introduced the same paired-state desync that caused the original gh#81 bug. **Regression test**: `TestHandleWindowSizeRewrapsMarkdown` — renders a long paragraph at width 100, resizes to width 40, asserts the new render has strictly more `\n`s than the wide one. ### 2026-04-21 — gh#81 PR merged Shipped. Final scope ended up well beyond the original bookmark bug once investigation surfaced three adjacent defects and coderabbit flagged four more on review. **The chain**: 1. gh#81 root cause: `handleBookmarkView` updated `m.links` but left `m.markedRendered`/`m.linkInfos`/`m.linkRegions`/`m.hoverIdx` pointing at the previous document — Tab in bookmarks view replayed the prior page's marked content via `processMarkers`. 2. Mouse wheel was never wired — `Update` dropped `tea.MouseWheelMsg`. Added forwarding to `m.viewport.Update`. 3. Tab after wheel-scroll snapped the viewport back to the old cursor position. Added `linkVisible(idx)` and `firstVisibleLinkIdx()` so Tab rebases from the first link visible in the current scroll window. 4. Markdown didn't re-wrap on terminal resize. Added `rewrapContent` helper called from `handleWindowSize`, preserving scroll via `viewport.ScrollPercent()` → `SetYOffset(percent * (TotalLineCount - Height))`. **Coderabbit follow-ups** (all valid, all merged): - Graph-mode resize with empty `graphNodes` fell through to the document branch and pasted the previous doc over the "Crawling…" placeholder. Made `viewMode == viewGraph` an unconditional outer case. - Resize didn't persist the new-width render into `m.history[m.histIdx]`. `restoreHistory` (called from `[`/`]` / esc / help dismiss) would snap back to pre-resize wrap. First pass scoped the writeback to the document branch; second pass generalized: extracted `rewrapHistoryEntry()` that re-renders from `entry.rawBody`/`entry.linkInfos` (not `m.rawBody`) and is called unconditionally from `rewrapContent` — so resize while in bookmarks/graph/help/error all refresh history correctly. - `strings.Count(cleaned, "\n")` is one less than the viewport's own `len(strings.Split(s, "\n"))`. Fixed by calling `m.viewport.TotalLineCount()` / `.Height()` directly. - Wheel scroll left `m.hoverIdx` pointing at the pre-scroll screen position (hover baked into `m.markedRendered`, not recomputed on scroll). Added snapshot-compare of `YOffset()` and reset `hoverIdx = -1` + `processMarkers` if the scroll actually moved; then gated the rehighlight path to document mode (`viewMode==viewDocument && !showHelp && err==nil && status!="bookmarks"`) so it doesn't paste stale `markedRendered` over graph/help/bookmarks viewports. **Lesson this session burned in hardest**: `m.links`, `m.linkInfos`, `m.markedRendered`, and `m.linkRegions` are a tuple describing "the currently-navigable content". Any mutation of one requires synchronized mutation of the others. Similarly `viewport.YOffset()` and `m.hoverIdx`/`m.linkIdx` are two cursors over the same content — any operation that moves one without the other creates the class of bugs that dominated this session. The `rewrapHistoryEntry` helper is the other recurring pattern: history entries are a separate source of truth and need their own refresh path, independent of what the live viewport is showing. **Regression tests added** (all in `links_test.go`): bookmark link-state reset, tab-resumes-from-scroll (4 subtests), resize rewrap, resize stays in graph mode while crawling, resize persists to history, resize refreshes from entry.rawBody, resize refreshes history while help visible, wheel clears hover on actual scroll, wheel preserves hover when no scroll, wheel does not rehighlight outside document view (3 subtests).