This is a personal patch, not a contribution to umputun/revdiff (see
docs/plans/completed/20260722-markdown-preview-mode.md for why). This file is the rebase
playbook: what the patch touches, and how to carry it onto a new upstream release.
Two different things both get called "master" in this repo, and this file means only the first one whenever it says "upstream":
- upstream — remote
upstream=umputun/revdiffon GitHub, the real open-source project. This is whatbrew/go installship. The patch NEVER goes here, in any form, ever. - the fork — remote
origin=p4elkin/revdiffon GitHub, the personal fork this patch lives in. The localmasterbranch tracksorigin/master, i.e. the fork, so a baregit pushfrommastergoes to the fork and cannot reach upstream by accident.masteris also where fork-only feature branches (includingmd-preview) get merged together before being pushed — see "Integrating into the fork's master" below. That push is a normal, expected use of this patch, not an exception to the no-upstream rule.
To take new upstream work, fetch it explicitly: git fetch upstream, then merge or rebase
upstream/master. A plain git pull on master follows the fork and does not do this.
(These remote names were swapped on 2026-08-11. Before that origin was upstream and the fork
was fork, which meant a bare git push from master aimed at umputun/revdiff. Any older
note or transcript using the old names is describing that layout, not this one.)
Because the patch never goes to upstream, it deliberately leaves every doc that describes the
released, brew-installed binary alone: README.md, site/index.html, site/docs.html, and
the plugin reference docs under .claude-plugin/ and plugins/. So the P key
(toggle_preview), the r key (toggle_raw), the ▤ status-bar icon, and the --preview
flag appear in neither the README keybindings/options tables, nor site/docs.html, nor any
plugin config.md/usage.md.
That is on purpose, not an oversight: editing them would describe a feature to people running
the real upstream binary, which does not have it — true whether those docs live on md-preview
or get merged all the way to the fork's master, since the fork's master is still not what
those docs are about. The P and r bindings and the --preview flag are discoverable at runtime instead, through the
in-app help overlay (?), --dump-keys, and --help/--dump-config, all of which read the
real keymap/options and so list them automatically in this build.
md-preview rebases onto upstream tags directly (see "Rebase procedure" below) and is not
itself pushed anywhere upstream. Separately, feature work meant for the fork as a whole —
whether upstream-portable (like --no-tree) or preview-only (like --preview) — gets merged
into the local master branch and pushed to the fork:
git checkout master
git fetch origin # origin is the fork; master tracks origin/master
git merge --no-ff md-preview # or a feature branch, if the feature isn't on md-preview yet
make test && make lint
git push origin masterThis is how the fork's master ends up ahead of upstream/master: it carries every fork-only
feature, preview included, published to the user's own fork, never to upstream.
- Branch
md-previewforks frommasterat commit1b563f8(2026-07-19), which isv1.11.1+ 10 commits. - Upstream tag
v1.11.1is commit39da604(2026-07-13). - Run
git merge-base md-preview masterandgit describe --tags <that-commit>to reconfirm this after any rebase — the base moves every time the branch is rebased onto a new tag.
-
app/ui/mdpreview.go— almost all patch logic lives here by design (fence extraction, glamour rendering, the preview-mode action allowlist,toggleMarkdownPreview). -
app/ui/mdpreview_test.go— its tests. -
app/ui/mdpreview_transpile.go— the classDiagram/stateDiagram-v2 → flowchart transpiler (seedocs/plans/completed/20260728-markdown-preview-diagram-transpile.md): diagram-kind dispatch, the sharedflowchartBuilder, both per-type transpilers, and the adaptive label-width cap. -
app/ui/mdpreview_transpile_test.go— its tests. -
app/ui/mdpreview_flowchart.go— thegraph/flowchartnormalization pass: rewrites the constructs the vendored parser mis-parses (node shape suffixes, non--->link forms, a|inside a node label) into the subset it reads correctly, and drops the styling and layout directives it cannot draw at all. Text-level, structure-preserving — it is NOT a transpiler and does not useflowchartBuilder. -
app/ui/mdpreview_flowchart_test.go— its tests. -
app/ui/mdpreview_subgraph.go— subgraph splitting for flowchart sources: detects when a diagram contains independent top-level subgraphs, decides whether it is safe to split, and renders each subgraph as a separate diagram stacked under its own title. Covers the five-rule split decision and the render + stack + fallback workflow. -
app/ui/mdpreview_subgraph_test.go— its tests. -
app/ui/mdpreview_nbsp.go— the shared no-break-space substitution used by every mermaid edge-label call site, replacing spaces somergeDrawingsin the vendored renderer treats the cell as opaque instead of letting the arrow line or a crossing edge bleed through (see "Edge-label rendering wiring" below). -
app/ui/mdpreview_nbsp_test.go— its tests. -
app/ui/mdpreview_collision.go— the edge-label collision detector plus the LR-direction retry gate: counts how many distinct labels land too close together on one row of rendered art, and decides whether a second render with the direction flipped to LR is strictly better. -
app/ui/mdpreview_collision_test.go— its tests. -
app/ui/mdpreview_wrap.go— the node-label wrap pass: breaks long node labels over several lines with<br/>so a diagram whose art overflows the pane is re-rendered narrower, gated on the art actually overflowing and kept only when it is strictly narrower and no more colliding (see "Long node labels are wrapped" below). -
app/ui/mdpreview_wrap_test.go— its tests. -
app/ui/mdpreview_corpus_test.go— the differential corpus harness used to verify both fixes against a large body of real fences; skipped unlessREVDIFF_MERMAID_CORPUSis set, so it never runs inmake testor CI. -
app/ui/mdpreview_marker.go— the zero-width marker mechanism preview annotations are built on (see "Preview annotations" below): the marker encoding, the tracked-block-kind table, and the style-cloning that prepends a marker without discarding an existing prefix. -
app/ui/mdpreview_marker_test.go— its tests. -
app/ui/mdpreview_blocks.go— the goldmark block walk: parses the placeholder document and emits one target per tracked block kind with its source line span, folding task-list items into their enclosing list item and a table into one target covering the whole table. -
app/ui/mdpreview_blocks_test.go— its tests. -
app/ui/mdpreview_srcmap.go— agrees the block walk's targets against the markers glamour left in the render and builds the row↔line map (mdPreviewSourceMap), or setsaligned = falseand exposes no targets when the two disagree. Also carries the mermaid-splice row-shift adjustment and the--no-colorsdegrade. -
app/ui/mdpreview_srcmap_test.go— its tests. -
app/ui/mdpreview_srcmap_corpus_test.go— the env-gated corpus harness that measures alignment success rate and per-kind anchor counts over a real document tree (mirrorsmdpreview_corpus_test.go's differential mermaid harness on the fork'smaster, which this branch predates — see "Preview annotations" → "Corpus measurement" below). -
app/ui/mdpreview_cache.go— the single-entry render cache (mdPreviewRenderCache) that keys the base render and its source map on(file.name, file.loadSeq, viewport.Width, noColors), plus the composed-frame assembly (mdPreviewBody,mdPreviewFrame,mdPreviewHighlight) that layers the block cursor's highlight and painted annotations on top of the cached base. -
app/ui/mdpreview_cache_test.go— its tests, plusBenchmarkMdPreviewRepaint. -
app/ui/mdpreview_annotate.go— painting existing annotations into the preview (mdPreviewPaintAnnotationsTracked, reusingrenderAnnotationOrInput/renderFileAnnotationHeaderrather than a parallel painter) and creating one (mdPreviewStartAnnotation,mdPreviewStartAnnotationAt,mdPreviewClickDiff, the live-input visibility helper). -
app/ui/mdpreview_annotate_test.go— its tests. -
app/ui/mdpreview_cursor.go— the driveable preview cursor: its tagged state (mdPreviewCursorState), the read/place/clear helpers, the center-of-viewport seed (mdPreviewViewportCenter,mdPreviewCenterBlock), the one-stop-per-press move with its minimal viewport follow (moveMdPreviewCursor,syncMdPreviewViewportToStop), and the drop-when-scrolled-out-of-view rule every viewport-only scroll goes through (dropMdPreviewCursorIfHidden,afterMdPreviewViewportScroll). See "Preview cursor" below. -
app/ui/mdpreview_cursor_test.go— its tests. -
app/ui/mdpreview_stops.go— what the cursor can stop on: the identity a stop is addressed by (mdPreviewStopRef), the stop and painted-annotation anchor types (mdPreviewStop,mdPreviewAnnotAnchor), the ordered stop list and the two lookups over it (mdPreviewSourceMap.stops/stopAt,mdPreviewStopIndex,mdPreviewNearestStop), anddinside preview (mdPreviewDeleteAnnotation,repaintMdPreviewAfterStopChange). -
app/ui/mdpreview_stops_test.go— its tests. -
app/ui/mdpreview_expand.go— raw source expansion for the selected block: the pass that swaps one block's rendered rows for its markdown source one row per source line (mdPreviewRawLines,mdPreviewExpandBlock,mdPreviewLineAnchor), the refusal rules that say whenrwill not expand (mdPreviewExpandRefusal— a code fence by block kind, a block whose every source line is blank), the recovery of a mermaid diagram's fence extent (mdPreviewOwnSpanEnd,mdPreviewMermaidFenceSpan), and therhandler itself (mdPreviewToggleRaw,mdPreviewExpandTarget,mdPreviewRawStopLine,mdPreviewCollapseRaw). See "Raw source expansion" below. -
app/ui/mdpreview_expand_test.go— its tests.
A clean rebase never conflicts on these thirty-two files — they don't exist upstream. All conflict risk is in the hunks below.
Confirmed against git diff master...md-preview --stat -- . ':!vendor'. Line numbers are
current HEAD of md-preview; they will drift after every rebase — treat them as a map of
what to look for, not permanent coordinates.
Task 4 wiring (the mode toggle itself):
app/keymap/keymap.go- line 55:
ActionTogglePreviewadded to theActionconst enum - line 95:
ActionTogglePreview: trueadded to thevalidActionsmap - line 238: help-section entry in
defaultDescriptions() - line 301:
"P": ActionTogglePreviewindefaultBindings()
- line 55:
app/ui/model.gomdPreview boolfield onmodeStatemarkdownPreviewable boolfield onloadedFileState— the real gate for preview mode (a full-context markdown file). It exists becausemdTOCis nil for a heading-less markdown file, somdTOC != nilwrongly refused preview for valid heading-less docs.keymap.ActionTogglePreviewadded to the toggle-groupingcasethat routes tohandleViewTogglecase keymap.ActionTogglePreview: m.toggleMarkdownPreview()insidehandleViewToggle
app/ui/loaders.go- in
handleFileLoaded, beside the existingmdTOCcomputation: setsm.file.markdownPreviewable = m.isMarkdownFile(msg.file) && m.file.singleColLineNum. This makesloaders.goa newly-touched file for the patch, so a rebase moving that block will surface a fresh conflict here. - the
mdTOCbuild below it gained its ownm.file.singleFile &&condition (second hunk in this file). The two conditions used to be one shared expression; they are now deliberately different, and each carries a comment saying so. See "Preview in multi-file reviews" below.
- in
app/ui/diffview.go- line ~280: early-return branch in
renderDiff()—if m.modes.mdPreview && m.file.markdownPreviewable { return m.renderMarkdownPreview() }, placed beside the existingm.modes.collapsed.enabledbranch
- line ~280: early-return branch in
app/ui/view.go- line 459:
{"▤", m.modes.mdPreview}added tostatusModeIcons()
- line 459:
That is 12 hunks across 5 files. The plan's Task 4 text counts by checklist item, not by literal
diff hunk (e.g. keymap.go's enum + validActions edit is one checklist item but two hunks), so
its site count differs — use the itemized list above as the actual hunk map.
--preview flag (start in markdown preview mode, added later):
Same reasoning as the P binding and ▤ icon above — this is a preview-only option, so it is
NOT added to README.md/site/docs.html/plugin config.md (which describe the released,
upstream binary, no preview mode). It IS expected to reach the fork's master via the merge described
in "Integrating into the fork's master" — that section, not this one, is what decides where a
feature ends up; this section only says why the docs stay untouched. Discoverable via --help
and --dump-config in this build.
app/config.go—Preview boolnext toCollapsed, tag`long:"preview" ini-name:"preview" env:"REVDIFF_PREVIEW"`, description "start in markdown preview mode"app/main.go—Preview: opts.Preview,in theui.ModelConfig{}literal, next toCollapsed:app/ui/model.goPreview boolonModelConfig(Configuration values section, next toCollapsed)NewModel'smodes: modeState{...}literal seedsmdPreview: cfg.Preview
- No new applicability gate needed (unlike
Compact'sCompactApplicable): the existing per-filemarkdownPreviewablecheck inhandleFileLoaded(loaders.go) already resetsm.modes.mdPreviewtofalseon the first file load if that file is not previewable, so an unusable seed (e.g.--previewagainst a non-markdown file, or a renderer/mode that produces a non-full-context diff) silently no-ops instead of needing its own gate.
Task 5 wiring (making annotation/cursor keys inert during preview — required additional edits beyond Task 4's file list, see the plan's Task 5 section for why):
app/ui/model.go- line 1038:
if m.modes.vimMotion && !m.modes.mdPreview {— one-token change to the guard that decides whether to runinterceptVimMotion - line 1072:
dispatchActionguard — nowif m.modes.mdPreview { if model, handled := m.handleMdPreviewAction(action); handled { return model, nil } }. It was originally the flatterif m.modes.mdPreview && !mdPreviewActionAllowed(action) { return m, nil }; the horizontal-panning work (below) needed preview to serve two actions rather than only block them, and the whole decision moved intohandleMdPreviewActioninmdpreview.goso this file keeps a single three-line hunk.dispatchActionwas split into a thin wrapper plusdispatchResolvedActionhere to keepgocyclounder the lint ceiling; a rebase that touches the old singledispatchActionbody must land insidedispatchResolvedActioninstead
- line 1038:
app/ui/mouse.goif m.modes.mdPreview { return false }at the top ofpinDiffCursorTo(keeps an allowed diff-pane scroll — J/K or wheel — from pinning/mutating the cursor)if m.modes.mdPreview { return m, nil }inhandleMouse's left-click branch (a click in either pane is read-only in preview — noclickDiff/clickTreecursor move)if m.modes.mdPreview { return m, nil }inhandleWheel'shitTreebranch (TOC wheel must not drivesyncDiffToTOCCursor, matching the swallowed n/N/p keys)
Review phase 4 wiring (stopping stale diff-cursor state from leaking into the display while previewing — same root cause as Task 5, caught at three more sites):
app/ui/diffnav.go— a new file for the patch to touch, so a rebase that movessyncViewportToCursorwill surface a fresh conflict here.- guard inside
syncViewportToCursor, between theSetContent(m.renderDiff())call and the YOffset-repositioning switch:if m.modes.mdPreview { m.layout.viewport.SetYOffset( m.layout.viewport.YOffset); return }. Keeps the re-wrap (needed on resize / tree toggle) but preserves the user's scroll instead of snapping to the frozen diff cursor; theSetYOffset(YOffset)re-clamps to the new content so a shrink cannot strand the offset past the end (blank screen). Reached in preview via the allowedtoggle_treeaction, terminal resize, and a blame load landing mid-preview.
- guard inside
app/ui/view.gohunkSegment(): guard widened toif m.layout.focus != paneDiff || m.modes.mdPreviewlineNumberSegment(): same widening. Both otherwise print a fake live "hunk X/Y" / "L:N/M" off the frozen cursor while the glamour render scrolls freely.statusSegmentsNoSearch/statusSegmentsMinimalneed no change — they call these two helpers, which now return"".- a comment on the
m.file.singleFile && m.file.mdTOC != nilrender branch documents the one display-staleness left unfixed: the TOC active-section highlight. It is pinned to the toggle-on section while previewing becausesyncTOCActiveSectionruns off the frozen cursor. Left as a MINOR limitation on purpose — a clean fix needs a newTOCRenderfield in the upstreamapp/ui/sidepanepackage (which this fork keeps untouched), and dropping the TOC pane during preview would desync the diff viewport's width geometry. It is a dim highlight, not a numeric tracker, so lower stakes than the status-bar segments, which ARE fixed.
Diagram transpile wiring (classDiagram/stateDiagram-v2 rendering, added later — see
docs/plans/completed/20260728-markdown-preview-diagram-transpile.md):
-
app/ui/mdpreview.go- line 173:
renderMermaidBlockgained a fourth parameter,paneWidth int— the diff pane's current width, needed by the transpiler's adaptive label-width cap - line 193:
renderMermaidBlocknow callsrenderMermaidSource(strings.Join(body, "\n"), paneWidth)instead ofmermaidcmd.RenderDiagram(strings.Join(body, "\n"), nil)directly — the one-line hook that givesmdpreview_transpile.goa chance to rewrite the fence before it ever reaches the third-party renderer: aclassDiagramorstateDiagram-v2fence becomesflowchartsource, and agraph/flowchartfence goes through the normalization pass inmdpreview_flowchart.go(see "graph/flowchart normalization" below) - line 272:
mermaidPlaceholderDocumentgained a matchingpaneWidth intparameter, passed straight through torenderMermaidBlockat line 275 renderMermaidFences(line 71) deliberately keeps its original one-argument signature: it passes themermaidUnconstrainedWidthsentinel internally, so its eleven existing callers inmdpreview_test.goneeded no change. It has no production caller — production always reaches the renderer throughrenderMarkdownDocument→mermaidPlaceholderDocument, which knows the real pane width. Treat it as a test-only entry point: anything that verifies width-dependent behavior must go throughrenderMarkdownDocumentinstead, or it will run at the unconstrained sentinel and never exercise the adaptive cap production always uses
Both edited lines live inside
app/ui/mdpreview.go, which is itself one of the patch's own new files (listed above) rather than an upstream one, so none of this carries rebase conflict risk against upstream. It is recorded here anyway so the call flow betweenmdpreview.goandmdpreview_transpile.gostays documented in one place, alongside every other hunk map entry. - line 173:
Horizontal panning wiring (reading mermaid art that is wider than the pane — replaces the old "wide diagrams are clipped" limitation):
app/ui/mdpreview.go— all of the new logic, in the patch's own file:mdPreviewCutWidth— how many columns of a preview row are visible. It is the viewport width, NOTapplyHorizontalScroll'sdiffContentWidth() - gutterExtra(): a preview row has no cursor bar, no gutters and no right padding column, so the diff basis would cut two columns short on every row.mdPreviewMaxLineWidth/mdPreviewMaxOffset— the pan clamp, measured on the rendered document. A clamp derived fromm.file.lineswould stop at the width of the mermaid fence's source line (a few dozen cells) instead of the art it renders to (200+).applyMdPreviewScroll/cutMdPreviewLine— the ANSI-aware cut, reusingansi.Cutand theleftScrollIndicator/rightScrollIndicatorglyph helpers so the visual language matches the diff pane. Both indicators are drawn inside the cut width, because the viewport truncates at exactly that width (the diff path can spill»into its own right padding).mdPreviewLeftIndicator/mdPreviewRightIndicator— plain glyphs under--no-colors, because the shared helpers fall back to reverse video there and the preview promises a zero-ANSI render.panMarkdownPreview— the pan itself; renders the document once and reuses that render for both the clamp and the cut, so a keypress costs one glamour pass, not two.handleMdPreviewAction— the new preview gate called fromdispatchAction. It wrapsmdPreviewActionAllowedand routesscroll_left/scroll_rightto the pan. The routing is required, not stylistic:scroll_rightdoubles as the focus-diff action inhandleTreeActionandhandleTOCNav(case keymap.ActionFocusDiff, keymap.ActionScrollRight:inapp/ui/diffnav.go), so letting it fall through would switch panes instead of panning.mdPreviewAllowedActionsgainedActionScrollLeft/ActionScrollRight; the allowlist stays the single source of truth (removing them there disables the pan).toggleMarkdownPreviewresetsm.layout.scrollXon both transitions — the offset is shared with the diff render but means different things in the two modes.handleFileLoaded(loaders.go) already reset it on file load, so no edit was needed there.
app/ui/model.go— no new hunk: the existingdispatchActionguard changed shape (see the Task 5 entry above).
Preview in multi-file reviews (the gate no longer demands a single-file review):
The first version of the gate also required m.file.singleFile, so P was inert in every review
holding more than one file — including a mixed markdown + java review where the displayed file was
a perfectly renderable full-context document. What makes a whole-document glamour render safe is
that the displayed file is full context (every line present, so no table or code block can be shown
half-rendered); the size of the review says nothing about that. The singleFile term was extra
caution, not a safety condition, and it is gone.
-
app/ui/loaders.go— the two conditions inhandleFileLoadednow differ on purpose:m.file.markdownPreviewable = m.isMarkdownFile(msg.file) && m.file.singleColLineNum— nosingleFileterm.- the
mdTOCbuild keepsm.file.singleFile &&of its own. The TOC renders into thepaneTreeslot (view.go'sm.file.singleFile && m.file.mdTOC != nilbranch), which in a multi-file review is the file tree's. Building a TOC there would replace the file list with a table of contents and leave no way to reach the other files. Preview has no such conflict — it renders into the diff pane, which belongs to the displayed file either way.
Because the two were one shared expression before, both carry a comment saying they are now different. A rebase must not "tidy" them back into one.
-
Nothing else changed.
mdTOCis nil in a multi-file review, so every TOC-dependent path (treePaneHidden,togglePane,toggleTreePane,handleTreeAction's TOC dispatch,handleFileOrSearchNav's TOC branch,view.go's TOC render branch) behaves exactly as it did before this change. -
Pane geometry: in a multi-file review with the tree visible,
handleResizesetsviewport.Width = width - treeWidth - 4, which is the same valueViewuses asdiffPaneW. The pan clamp readsmdPreviewCutWidth()=viewport.Width, so it clamps against the narrower two-pane width and the»glyph still lands inside the pane. Pinned byTestView_MdPreviewInMultiFileReview_PaneGeometryIntactandTestPanMarkdownPreview_MultiFileReview_ClampsAgainstTwoPaneWidth. -
mdPreviewAllowedActionsis unchanged.next_item/prev_itemstay blocked — see the expanded comment above the map, and the "must leave preview before changing file" limitation below.
Edge-label rendering wiring (no-break spaces and the LR collision retry, added later — see
docs/plans/completed/20260805-mermaid-edge-label-rendering.md):
app/ui/mdpreview_transpile.gomermaidEdgeLabel— thestrings.ReplaceAll(s, " ", "·")substitution and themermaidDotRuncollapse were removed; the function now callsmermaidNBSPSubstitute(mdpreview_nbsp.go) as its sole space-handling step, aftermermaidSafeTextand before the rune/byte cap.renderMermaidSource— after the existingmermaidcmd.RenderDiagram(toRender, nil)call, the result is passed throughmermaidRetryLRIfColliding(toRender, rendered, paneWidth, func(s string) (string, error) { return mermaidcmd.RenderDiagram(s, nil) })before thereturn rendered, nil.paneWidthis the same value the transpilers already receive, and the retry needs it to refuse a flip that would push a fitting diagram off the pane. The transpile and subgraph-split paths above that call are untouched — the retry only ever sees the final whole-source (or per-subgraph) render.
app/ui/mdpreview_flowchart.gounquoteFlowchartEdgeLabelrenamed tonormalizeFlowchartEdgeLabeland restructured so the no-break-space substitution runs on every path that has a label, not only the previously-quoted one; it is now the single chokepoint for both edge-label spellings (normalizeFlowchartLinenormalizes links before nodes, so an inline-- label -->has already become-->|label|by the time this function runs).
Both files are already patch-owned (listed under "New files added by the patch" above), so none of this carries rebase conflict risk against upstream — recorded here for the call-flow map, same reasoning as the "Diagram transpile wiring" and "Horizontal panning wiring" entries above.
Preview annotations (reading and creating annotations inside preview, this
plan's own feature — see docs/plans/completed/20260811-preview-annotations.md):
The mechanism: every block glamour renders writes a zero-width marker into its
style's prefix (mdpreview_marker.go); a goldmark walk over the same document
independently lists what block starts on what source line
(mdpreview_blocks.go); the two sequences are agreed kind-for-kind and
row-for-row (mdpreview_srcmap.go) into mdPreviewSourceMap, which answers
"which source line did this rendered row come from?" or, on any disagreement,
refuses to answer at all (aligned = false, no targets exposed). Everything
downstream — the highlight, the painted annotations, a, a click — is built
on that one map and that one refusal contract.
app/ui/model.gomdPreviewCache *mdPreviewRenderCachefield onModel, seeded inNewModel— held behind a pointer for the same reasonrenderCacheis (renderMarkdownPreviewhas a value receiver; a plain field would memoize into a copy the method throws away).dispatchAction'smdPreviewguard now threads atea.Cmdthrough:handleMdPreviewActiongained a third return value, and this call site forwards it instead of always returningnil. The only case that ever produces a non-nil cmd isActionConfirmstarting an annotation input (startAnnotation'sti.Focus()cmd, ordinarily nil under this fork'scursor.CursorStatic— see gotchas.md's per-line render cache note).
app/ui/mouse.go— both hunks are a single call line, with the preview logic itself in the patch-owned preview files, the wayhandleMdPreviewActionwas moved out ofmodel.go.- the preview branch of
handleMouse's left-click case now checkszone == hitDifffirst and callsmdPreviewClickDiff(mdpreview_annotate.go— maps the clicked row through the current source map to a block and starts annotating it); every other zone still falls through to the old read-onlyreturn m, nil. flushWheelPendinggained a two-lineif m.flushPreviewWheelPending() { return }at its top (mdpreview_cache.go):pinDiffCursorTois an unconditional no-op in preview (nothing to pin), so without it the deferred repaint the wheel-burst debounce owes never landed. That helper now also drops the block cursor once per burst when the burst carried its block off screen (see "Preview block cursor" below), repaints once and clears bothrenderPendingandtickInFlight— it rides the SAMEwheelStatedebounce documented in gotchas.md rather than adding a second one, per that task's explicit warning.
- the preview branch of
mdPreviewAllowedActions' doc comment (mdpreview.go, a patch-owned file, so
this carries no upstream conflict risk) was rewritten because its blanket
justification stopped being true: the whole map used to be excluded for one
shared reason ("would create, edit, delete, or navigate to an annotation, or
move m.nav.diffCursor"), and ActionConfirm is now the one exception —
allowed, and it DOES create an annotation, anchored through the source map
instead of through m.nav.diffCursor's ordinary meaning. Each remaining
exclusion now has its own inline reason rather than sharing the old blanket
one. ActionConfirm is routed INSIDE handleMdPreviewAction rather than only
added to the allowlist map, because its ordinary fall-through target
(handleEnterKey) branches on pane focus and would run the TOC jump — in
diff-line coordinates the preview render does not have — if the tree/TOC pane
happened to have focus while previewing. ActionAnnotateFile (A) and
ActionFlushOutput (O) needed no such routing: both fall through to their
ordinary handlers unmodified, because
neither reads or assigns m.nav.diffCursor (a file-level annotation's Line
is always 0, which mdPreviewPaintAnnotationsTracked already paints ahead of
every block unconditionally) and neither touches the viewport.
Keeping the annotation input on screen in preview. mdPreviewStartAnnotationAt
saves and restores viewport.YOffset around startAnnotation, so that
ensureLineAnnotationInputVisible (app/ui/annotate.go, upstream-owned) cannot
reposition the preview off diff-line coordinates that mean nothing against a
whole-document glamour render. That is still exactly what the save/restore is for
and it has not changed. What it also did, until this fix, was leave NOTHING to
scroll the input into view: the input is spliced under the block being annotated,
so mid-document nobody noticed, but with the block on the bottom edge of the
viewport — the last block of a document above all — the input landed one row
below the window and the reader typed blind.
So the preview grew its own visibility step, in preview row coordinates:
mdPreviewSourceMap.liveInput(mdpreview_srcmap.go) — the row span of the input the reader is typing into, recorded by the painter that spliced it, the same way every other anchor in that map is recorded rather than found by scanning the painted string. It is the one span in the map that is not a cursor stop, because a live input is not in the store and so has nomdPreviewAnnotAnchor.mdPreviewAnnotRungained aliveflag to carry it out ofmdPreviewCollectAnnotationRows, set both for a brand-new input and for the stored annotation whose rows ARE the input while it is being edited.ensureMdPreviewInputVisible(mdpreview_annotate.go) — called at the tail ofmdPreviewStartAnnotationAt, after the content refresh soSetYOffsetclamps against a buffer that already holds the input's row.syncMdPreviewViewportToRows(mdpreview_cursor.go) — the row-span half ofsyncMdPreviewViewportToStop, which now delegates to it. Same minimal scroll, never centering.
No upstream-owned file gained a hunk for any of it. A (file-level) needed
nothing: startFileAnnotation ends in GotoTop, and the file-level input is
painted at row 0, so the top of the document IS the minimal scroll there.
ActionConfirm focus branch takes focus rather than doing nothing.
With the tree/TOC pane focused it assigns m.layout.focus = paneDiff and
returns, so the second press annotates — the same progression
handleEnterKey's own paneTree branch gives source view. A bare no-op there
made a and A permanently dead in every multi-file review: paneTree is
the focus NewModel starts in (only single-file mode flips it to paneDiff,
in handleFilesLoaded), and toggle_pane / focus_tree / focus_diff are all
excluded from the allowlist, so no key could hand focus back. A keeps its own
unmodified diff-pane-only gate and becomes reachable once a has moved focus.
A refused preview annotation is not silent either: mdPreviewStartAnnotation sets
m.preview.hint (an mdPreviewState, the same shape as outputState /
compactState, rendered by transientHint and cleared on the next key or mouse
event) when neither the block cursor nor the center-of-viewport seed resolves a
block. Since the block cursor landed that is exactly the unaligned-document case
— README.md is one — where the frame is otherwise byte-identical and the
reader cannot tell "this document cannot be anchored" from "the key is not
bound". The message is the constant mdPreviewUnanchorableHint; the old second
message ("No block in view to annotate") is gone with the scroll-derived
highlight, because an aligned map always has a nearest block to seed on.
The same message is now also shown once on entering preview, not only after a
key is pressed. toggleMarkdownPreview asks mdPreviewCanAnchor (map aligned and
at least one block — the base render's map, which is the memo the frame it just
drew already filled, so it costs no second glamour pass) and sets the hint on the
ON transition when the answer is no. It is an ordinary transient hint, so the next
key clears it and it never occupies the status bar. Before this, a correct refusal
was still a mystery: the reader had to discover it by pressing a and watching
nothing happen, which is what "sometimes I don't get annotation capabilities in
preview mode, seems a bit random" turned out to mean.
The render cache (mdpreview_cache.go) is a single entry, not a map — the
preview shows one file at a time, so there is never more than one base render
worth keeping warm (same shape as diffRenderCache's own per-line, not
per-file, single-generation cache). Key: (file.name, file.loadSeq, viewport.Width, noColors). loadSeq is what makes the key safe across an R
reload of the same file at the same width — renderMarkdownPreview's old doc
comment explicitly declined a cache for exactly this reason before loadSeq
closed it, the same way globalRenderKey already relies on it (see
gotchas.md's per-line render cache note).
Two more memos sit beside it on mdPreviewRenderCache, both keyed on the
painted body string rather than on the cache key, because the body is the base
render PLUS this file's annotation rows and changes on every annotation edit:
the widest-row width (ansi.StringWidth over the whole document, the pan
clamp's basis) and the horizontal cut itself. Neither depends on the vertical
offset, so the repaint every j/k keypress now drives — the block highlight
has to follow the viewport — reuses both.
Measured on Apple M2 Max against
docs/plans/completed/20260722-markdown-preview-mode.md (810 source lines, 983
rendered rows, 3 mermaid fences), pane width 80, a resolver carrying a search
background so the highlight really paints:
| path | ns/op | B/op | allocs/op |
|---|---|---|---|
fresh mdPreviewRenderWithMap (what a repaint cost before the cache) |
104,003,012 | 42,925,885 | 415,344 |
| repaint frame, before the width/cut memos | 6,222,047 | 1,409,447 | 1,092 |
repaint frame, current (mdPreviewFinalRender) |
124,481 | 671,822 | 21 |
| cached base-render lookup alone — NOT a repaint | 502.5 | 0 | 0 |
~523ns, "about 215,000x"). The benchmark it
came from timed mdPreviewBaseRender — a key comparison and a string return —
while a repaint is mdPreviewFinalRender: cached base render, annotations
painted in, the horizontal cut, the highlight. Mislabelling it is how a
full-document width scan came to sit unnoticed in every keypress. The honest
figure for the repaint a block-cursor move actually pays is the
third row: ~0.12ms, down from ~6.2ms, and ~835x below a fresh render. The
remaining cost is the highlight's own per-row pass, which cannot be cached
because it moves with the offset.
Preview cursor (the highlight became something the reader steers):
The preview annotations above shipped with a highlight derived from scroll
position: mdPreviewHighlightAnchor computed the topmost fully visible block
from viewport.YOffset and held no state. In use that marked a block nobody had
chosen, always near the top of the pane, and it could not be aimed. It is now a
real cursor over stops.
A stop is a rendered block, or a single annotation painted under one. It started
out as blocks only, which meant j/k stepped straight over every painted
comment: nothing could select one, so nothing could delete one either, and d
was excluded from the allowlist for exactly that reason. Annotations are now
stops of their own.
The whole feature lives in the patch's own files. No upstream-owned file gained
a hunk for it — not model.go, not loaders.go, not mouse.go, not
keymap.go. Three design choices are what bought that, and all three are worth
keeping through a rebase:
- The cursor is tagged, not reset.
mdPreviewCursorStatestores the stop together with thefile.name+file.loadSeqit was placed under, and reports "nothing selected" whenever the tag does not match the current load. A file switch and anRreload both bumploadSeq, so both leave nothing selected with no reset inhandleFileLoaded(loaders.go) to remember. It is the same seq-taggingcompactState.pendingAnchoruses. The zero value means "nothing selected", soNewModelneeds no initializer either. - A stop is addressed by identity, never by its index in the stop list
(
mdPreviewStopRef: a block index, plus which annotation under it if any). The list changes length under a cursor that never moved —Aprepends a file-level stop,dremoves one from the middle — and an index would silently come to mean a different stop. An identity survives both, and it is what makes the after-delete placement exact rather than approximate. - The reading keys and
dare routed insidehandleMdPreviewAction, which already existed.ActionDown/ActionUp(j/k AND the arrows — one action each, they cannot be told apart and are not meant to be) move the cursor;ActionScrollDiffDown/ActionScrollDiffUp(J/K) joined the routed set so the scroll can drop the cursor and repaint, which the fall-throughscrollDiffViewportLinenever did in preview (pinDiffCursorTois a no-op there, so it repainted nothing);ActionDeleteAnnotation(d) joined it too — see the delete rules below for why adding it to the allowlist alone would be wrong.
Behavior, in one place:
- nothing is highlighted until the reader moves. Entering preview, a file load
and an
Rreload all leave the cursor unset. j/k(and the arrows) with no cursor SEED it at the stop nearest the vertical center of the viewport and stop there; with a cursor they move one stop and clamp at the first and the last, no wrap.- stop order is paint order: the file-level annotation (painted above the whole
body), then each block followed by the annotations painted under it. So
jfrom a block reaches that block's own first annotation before the next block. For an ORDINARY block that order is not imposed by the stop list — it follows from the paint geometry, since block i's annotation rows sit at the top of the gap between block i'sendRowand block i+1'srow(the rest of that gap is the padding glamour leaves between blocks). The EXPANDED block is the one place it IS imposed: its raw source lines and its comments interleave, sostops()merges the two lists by ascending row (mdPreviewMergeStopsByRow) and drops the block's own stop while it is expanded. See "Raw source expansion" below. - the highlight marks the cursor's own rows: a block's rows on a block stop, the
annotation's own rows on an annotation stop. What is marked is always what
aanddwill act on. - the viewport then follows minimally —
syncMdPreviewViewportToStopissyncViewportToCursorin preview-row coordinates, including its "taller than the pane shows its start" clamp. It never centers: centering is what made the old top-edge behavior jumpy. J/K, page/half-page, home/end and the wheel stay pure viewport scroll, and each drops the cursor when its stop has gone entirely off screen. That keeps the feature's invariant: you always see what you are about to annotate or delete. The wheel does it influshPreviewWheelPending, once per burst, not per event.awith no cursor seeds at the center block and annotates that, so it is never a dead key. On a block stop it annotates that block's start line; on an annotation stop it EDITS that annotation, aimed at its own(Line, Type). A click sets the cursor to what it hit, in addition to annotating it: a raw source line of an expanded block when the clicked row carries a line anchor (srcMap.lineAt, tried first), and otherwise the block the row belongs to.ddeletes the annotation the cursor is stopped on, then leaves the cursor on the block that owned it — except inside an expanded block, where it stays expanded and lands on the raw line the comment was attached to (see "The cursor became two-level" below). On a block stop, or with nothing selected, it refuses with a transient hint (mdPreviewDeleteNeedsAnnotationHint) rather than removing the block's annotations wholesale.- a document whose source map did not align has no stops to steer between, so
j/kfall back to a one-row scroll there rather than becoming dead keys.
Editing is the pre-existing diff-pane path, not a new mechanism.
startAnnotation already pre-fills its input from the store for the same
(Line, Type) and Store.Add already replaces on that key, so aiming a at the
selected annotation's own line IS the edit. The multi-line case comes with it
unchanged: a comment containing newlines is stashed in annot.existingMultiline
instead of being loaded into the textinput (whose sanitizer would flatten it), so
the editor key seeds $EDITOR from it and Enter on an empty input preserves it
rather than blanking it. No edit action and no new binding were added.
d must stay routed inside handleMdPreviewAction, not merely allowlisted.
Its fall-through target deleteAnnotation (app/ui/annotate.go) reads
m.nav.diffCursor + m.annot.cursorOnAnnotation, which preview drives neither
of, and its tail can call requestFileDiff — a file load, mid-delete, out of
preview. mdPreviewDeleteAnnotation deletes off the preview cursor instead. It
does still refresh the tree filter and follow a selection the refresh moved off
this file, exactly as source view does: the alternative is a file tree claiming
this file carries annotations after its last one was deleted.
mdPreviewHighlight runs AFTER
applyMdPreviewScroll's memoized cut (mdPreviewFrame owns that order, for the
reasons in its doc comment), so a cursor move misses nothing — the pass it
changes is the only pass that is not memoized. Moving the highlight inside the
cut memo would make every cursor move serve the previous frame until some other
input changed. TestMdPreviewFinalRender_CacheDoesNotServeAStaleFrameAcrossACursorMove
reaches the second cursor state on a model whose memos were warmed by the first,
which is the only shape that catches it; it was verified by actually making that
mutation. A DELETE is a different question and needed checking separately, since
it changes the painted body rather than only the highlight: the scroll memos key
on that body string and so miss by themselves, which
TestMdPreviewDeleteAnnotation_RepaintDoesNotServeAStaleFrame pins on a model
whose memos were warmed with the annotation still present.
Raw source expansion (r shows the selected block's markdown source, added later — see
docs/plans/completed/20260811-preview-raw-block-expansion.md):
Press r on the block the preview cursor marks and that block is redrawn as its raw markdown
source, one rendered row per source line. j/k then step between those source lines and a
annotates the exact line. Press r again, or esc, and the block goes back to its rendered form.
A mermaid diagram expands too (added after the first version of the feature — see "A mermaid
diagram expands to its fence source" below); a code fence still does not.
Only one upstream-owned file gained hunks for this, and they are all four in app/keymap/keymap.go:
app/keymap/keymap.go- line 56:
ActionToggleRaw Action = "toggle_raw"added to theActionconst enum - line 96:
ActionToggleRaw: trueadded to thevalidActionsmap - line 239: help-section entry in
defaultDescriptions()—{ActionToggleRaw, "toggle raw source for the selected preview block", "View"} - line 302:
"r": ActionToggleRawindefaultBindings()
- line 56:
app/ui/model.go is deliberately untouched. dispatchAction already routes every action through
handleMdPreviewAction while preview is on (that hunk landed with Task 5 above), so both the
allowlist entry (keymap.ActionToggleRaw: true in mdPreviewAllowedActions) and the handler case
live in fork-owned app/ui/mdpreview.go. Outside preview mode toggle_raw reaches
dispatchResolvedAction, matches no case there, and falls through to the pane handlers — the same
path any unbound key already takes, which is why it needs no new guard. It is not literally inert,
though, and the earlier wording claiming that was wrong: with the file tree focused the pane
handler clears pendingAnnotJump and nav.pendingHunkJump, and with the markdown TOC pane focused
(single markdown file, preview off) handleTOCNav's default arm runs EnsureVisible +
syncDiffToTOCCursor, which reassigns m.nav.diffCursor and top-aligns the viewport. Neither is a
regression introduced by this key — both are what pressing any unbound key does today — but a
future change that makes an unbound key in those panes do something visible inherits r with it.
The rest of the feature is fork-owned: the pass and the r handler in the new
app/ui/mdpreview_expand.go; the second cursor level in mdpreview_cursor.go and
mdpreview_stops.go; the per-line annotation splice in mdpreview_annotate.go; the wiring line in
mdpreview_cache.go; and the lines field plus spliceRow in mdpreview_srcmap.go.
The cursor became two-level, and the cursor is what carries the expansion:
-
A raw source line is a stop of its own.
mdPreviewStopRefgained anonLine bool+line intpair beside the existingonAnnot bool+annot int, and for the same reason that type's doc comment already gives — a stop is addressed by identity, never by its index in the list.lineholds the diff-line index (mdPreviewLineAnchor.lineIdx), which is also the coordinateaneeds, so "annotate this raw line" reads straight off the ref with no translation. A blank source line paints a row but gets no stop:mdPreviewHighlightskips blank rows inside a span, and a stop nobody can see would break the invariant that you always see whatawill act on. -
One block is expanded at a time, and it is always the block the cursor is in. The state is a single
expanded boolonmdPreviewCursorState, meaning "ref.blockis drawn as raw source". Putting it there is what makes a file switch and anRreload collapse the block for free — that struct already carries thefile.name+file.loadSeqtag, and every existing placement helper assigns a fresh struct literal, soexpandeddefaults back to false on every path that was not written for this feature. The failure direction is "collapsed when I did not expect it", never "a stale expanded block with a row map that does not match the screen". Two setters deliberately opt out of that default:setMdPreviewLineCursor(the only production writer ofexpanded = true) andsetMdPreviewCursorRefKeepingExpansion, which preserves it only when the target ref names the block that is already expanded. -
Expansion ends when
rorescis pressed, the cursor moves to another block, the whole expanded block scrolls off screen, the file changes, orRreloads. A scroll within a long expanded block does not end it:dropMdPreviewCursorIfHiddenre-seats the cursor onto the nearest still-visible stop of that block instead of clearing it. -
jandkclamp inside the expanded block rather than stepping out of it, viamdPreviewBlockStopRange. A flat clamp over the whole stop list would only hold for the document's first and last block; anywhere else a held-downjwould silently throw away the reader's expansion. -
The pass runs on the cached base render, so no memo key changed.
mdPreviewBodyis now three stages — cached base render, thenmdPreviewExpandBlock, then the annotation painter. Expansion is not an input to the glamour render, so pressingrcosts no glamour pass; the downstreammdPreviewScrollCachekeys on the body string, which already carries the expansion, so it misses exactly when it should.⚠️ The no-expansion path must return the same string value it was handed, not a rebuilt copy, ormdPreviewScrollCache.forBody'sbody == c.bodycompare degrades from a shared-pointer check to a full memcmp on every frame. -
dand a click behave differently inside an expanded block.dnormally leaves the cursor on the block that owned the deleted comment, which would collapse an expanded one — deleting a comment is not a request to stop looking at the source. SomdPreviewCursorAfterDeletekeeps it expanded and lands on the raw line the comment was attached to, falling back to the block's first raw line. A click resolves the clicked row to a raw-line stop first (srcMap.lineAt), before theanchorAtRowfallback that resolves a row to the block around it. -
The stop order for the expanded block is imposed, not geometric. Everywhere else
stops()lists a block then its comments and that is already ascending-row order. Inside an expanded block the raw lines and the comments interleave, so the two lists are merged by ascending row (mdPreviewMergeStopsByRow) and the block's own stop is dropped while it is expanded — a stop covering every raw line at once would makeaambiguous about which line it meant. -
A block's raw span is re-cut to the lines its own rendered rows came from, in both directions. Rows TILE —
mdPreviewBuildSourceMapcloses each block off at the row before the next one starts, so every row belongs to exactly one block — while source spans OVERLAP:swallowedSpan(mdpreview_blocks.go) excludes a nested boundary child from a list item's or blockquote's aggregation while leaving it inside the resulting min/max range, so a container spans source lines whose rendered rows belong to the nested block.mdPreviewClipRawSpanreconciles the two, and each direction fixes a bug that reached a build:- it SHRINKS a container at the next block's first source line. Painting the container's whole span into the few rows it owns drew the nested block's content as source AND left it rendered underneath — the same text twice on screen.
- it GROWS a nested block over the enclosing container's continuation, bounded by how far any
EARLIER block's source reaches. The rows a nested list owns run to the row before the
container's next sibling, which includes the rows the container's own trailing paragraph was
rendered into; painting only the nested list's own lines there DELETED that paragraph from the
frame for as long as the block stayed expanded, with nothing on screen to say so. So a
container's post-nested-block lines belong, for this pass, to the nested block whose row range
they are rendered inside — which is also what makes them reachable by
j/kanda.
The growth stops at the enclosing container's reach rather than running to the next block, so a paragraph that merely precedes a fence does not show the fence's opening marker as its own last source line. Trailing blank source lines are dropped for the same symmetry from the other end, because
mdPreviewExpandBlockkeeps the block's trailing blank RENDERED rows (they are glamour's padding between blocks) rather than replacing them.TestMdPreviewRawExpansionCorpusKeepsEveryLetter(mdpreview_srcmap_corpus_test.go, env-gated on the same corpus list as the alignment harness) is the mechanical net under all of it: it expands every block of every corpus document and fails if a letter disappears from the frame. The deletion above survived a review and a round of fixture tests because every fixture expanded the CONTAINER, never the block nested inside it. -
A mermaid diagram expands to its fence source, a code fence does not, and the asymmetry is the point. A code fence renders as its own lines, roughly one for one, so expanding it would add the fence markers and nothing else — it stays refused by kind. A diagram renders as box art that carries none of its source's text, so expanding is the only way to read or comment on the definition ("this edge label is wrong", "this node should be a decision"), which is what the feature is for. The first version refused it with
Diagram source is one line; that was a fact about the ANCHOR, not about the document, and the refusal and its hint constant are both gone now.- The anchor really does carry one line:
joinWithMermaidFencesreplaces the whole fence with a single placeholder paragraph and attributes every line of the replacement to the fence's opening line, so the block arrives as anmdBlockParagraphspanning only the```mermaidline while the ROWS it owns are the art rows the splice put there.mdPreviewOwnSpanEndrecovers the fence's real extent — opening line to closing fence, inclusive — by scanning forward withjoinWithMermaidFences' own closing rule (mdPreviewMermaidFenceSpan: same fence character, a marker at least as long, nothing but whitespace after it,ChangeDividerrows skipped). ⚠️ The widening happens at EXPANSION time, insidemdPreviewClipRawSpan, not wheremdPreviewBuildSourceMapbuilds the anchor. The deciding fact is thatendLinehas exactly one consumer — the expansion pass — so widening the recorded anchor would mean threading the source lines into the map builder to change a field nothing else reads, and would leave the map claiming a span its own row range was never derived from. If a second reader ofendLineever appears, the widening belongs in the builder instead.- Both fence markers are painted as rows of their own. The pass's whole arithmetic is "one source line, one rendered row", and the opening line is where a comment on the diagram anchors anyway.
- The nesting case needs no special handling, and not by luck: the placeholder is written at
column 0, so a diagram indented inside a list item breaks out of the item and becomes its own
top-level block. A diagram inside a BLOCKQUOTE does not exist at all —
> ```mermaiddoes not read as a fence opening tojoinWithMermaidFences, so it is never substituted and renders as an ordinary code fence. ⚠️ TestMdPreviewRawExpansionCorpusKeepsEveryLetter's property does not hold for a diagram, and the harness now computes that one expectation differently (mdPreviewCorpusExpected). The art is a drawing produced FROM the definition, not a typesetting of it: the renderer writes text into it that appears in no source line —implementson a<|--edge,+ChangedFiles...for a truncated member. Measured, not assumed: the classDiagram indocs/plans/20260730-preview-manual-test-plan.md"loses" exactly threemand threepon expansion, the three synthesizedimplementslabels. For a diagram the art's own rows drop out of the expectation and the fence's source lines are added to it, which is stronger rather than weaker — every row outside the block still has to survive letter for letter, and the definition now has to be proven on screen.
- The anchor really does carry one line:
-
What the painted map says is expanded beats what the cursor says. The two can disagree — the cursor still carries
expandedwhilemdPreviewExpandBlockrefused, e.g. against a map built at another width. Every reader holding a painted map (moveMdPreviewCursor's j/k clamp,reseatMdPreviewCursorInExpandedBlock,mdPreviewCursorAfterDelete) therefore asksmdPreviewSourceMap.expandedBlock, notModel.mdPreviewExpandedBlock: the clamp is clamping into that map's stop list, and reading the cursor instead could narrow j/k to one block's stops in a frame showing no raw source at all, with no key able to step out.mdPreviewCollapseRawis the deliberate exception — it asks the cursor, because it is the escape hatch that clears the flag. -
Annotations splice under the raw line they belong to.
mdPreviewSourceMap.spliceRowreplaced "splice at the block'sendRow" with "splice at the row the map names for this source line", and the painter's anchor shifting became a prefix sum over the splice points. The deciding case is the live input: without it, pressingaon raw line 4 of a thirty-line table puts the box you are typing into below raw line 30. -
A block's
endRowstops at its last row with text on it.mdPreviewBuildSourceMapcloses a block off by walking back from the row before the next block starts (from the end of the render for the last block) over glamour's padding rows. Before that trim the padding was inside the span, so an annotation spliced atendRow— the saved one and the live input alike — floated a blank row below the paragraph it commented on, unlike the diff pane where a comment sits directly under its line. It showed up only for blocks glamour actually padded: consecutive list items are adjacent and never had the gap, which is why it read as intermittent. The trim is in the map rather than in the painter on purpose — the painter still splices atendRowand still shifts by a strict prefix sum, so annotation rows still land BELOW the block's own span and the block highlight still marks the block alone. Trimming at splice time instead would have put the splice inside the span and dragged the highlight over the annotation. Every other reader ofendRowis unaffected:mdPreviewHighlightalready skips blank rows, andmdPreviewExpandBlockalready trimmed the same rows itself (mdPreviewTrailingBlankRows, now a no-op for a map built from a render and kept only for hand-built ones).
Test-only, mechanical, not part of the feature itself:
app/keymap/keymap_test.go— assertsPresolves toActionTogglePreviewapp/ui/view_test.go— one pre-existing test (TestModel_StatusBarFilenameTruncationWideChars) had its pinnedm.layout.widthbumped from 45 to 47 because the new status icon consumed the test's last 2 columns of slack (see the plan's Task 4⚠️ note — not a bug, the test was already at its limit)
app/ui/diffview.go and app/ui/model.go are the most actively developed files upstream and
the most likely to conflict — this was flagged going in (see the plan's "Patch discipline"
section) and confirmed empirically: model.go alone carries 6 of the patch's 25 existing-file
hunks (keymap 8, model 6, loaders 2, diffview 1, mouse 3, view 4, diffnav 1).
The vendored mermaid-ascii renderer only understands graph, flowchart, and
sequenceDiagram natively. The patch widens that in two different ways — one adds new diagram
types, the other fixes the two the renderer already claimed to support:
- Transpiled to
flowchartsource first:classDiagramandstateDiagram-v2/stateDiagram(seetranspileMermaidinmdpreview_transpile.go). - Normalized in place:
graphandflowchart. These are no longer a pure pass-through. They now go throughnormalizeFlowchartSourceinmdpreview_flowchart.go, which rewrites the constructs the vendored parser silently mis-parses, drops the directive lines it cannot draw, and copies everything else through byte for byte. See "graph/flowchart normalization" below. - Native, untouched path:
sequenceDiagram. It takes a completely different code path inside the vendored library and is pinned as byte-identical to a directRenderDiagramcall. - Still fall back to the verbatim fence text:
erDiagram,gantt,quadrantChart, and any other/unrecognized fence language. Measured against a real corpus, these three types covered 45 non-rendering fences before the transpiler;erDiagramnever actually appeared in that corpus. This stays out of scope on purpose — see the diagram-transpile plan's Overview for the fence-count breakdown.
The vendored parser reads exactly three shapes — Id, Id[label], and lhs --> rhs with an
optional -->|label|. Anything else is mis-parsed silently, so the old pass-through shipped
broken art for most real fences. Measured 2026-07-29 over the 138 unique graph/flowchart
fences in the author's document corpus (every .md under ~/.claude/plans and ~/dev,
excluding vendor/node_modules), 86 of 138 contained at least one mis-parsed construct, and
76 of 138 actually drew at least one stray box — a box whose label is a whole source line.
What normalizeFlowchartSource rewrites, and what each one did before:
- Shape suffixes → square brackets.
A{d},A(d),A([d]),A((d)),A(((d))),A[[d]],A[(d)],A{{d}},A>d]all becomeA[d]; the node id is untouched. Before, a shape suffix split ONE node into two boxes (aBbox and a separateB{d}box) with the edges divided between them. 45 corpus fences used a diamond, 9 a round/stadium shape. - Link variants →
-->.---,-.-,===,-.->,==>,--o,--x, longer dash runs, and the inline-labeled forms-- text -->/-. text .->/== text ==>(the label carried over as-->|text|).<-->stays bidirectional. Before, none of these matched any parser pattern, so the whole line became one box literally namedA --- B. 12 corpus fences used---, 12 used-.->, and ~40 an inline-labeled form. |inside a node label →/. The edge pattern's label group is greedy, so one stray pipe in a label swallowed half the line:A -->|go| B["x=with|without"]capturedgo| B["x=withas the edge label and leftwithout"]as the target node.- Quotes around an edge label → dropped.
A -->|"listVariants (strict mode)"| BbecomesA -->|listVariants (strict mode)| B. Mermaid quotes a label to protect the spaces in it, a job the|...|delimiters already do here, and the vendored renderer draws a label verbatim — so the quote marks showed up in the art.flowchartLinkTextalready unquoted a label, but only for the labels it BUILDS, the ones written in the inline-- label -->form. A label written the common way, straight after the arrow, matches neitherflowchartLinkPatternalternative: the arrow matches the bare-link alternative and the|...|after it was copied through untouched. The rewrite happens in the node pass, on each arrow segment that pass already consumes whole (normalizeFlowchartEdgeLabel). Exactly one layer comes off, and only when the label opens and closes with a quote and carries none inside — so|"a" and "b"|keeps both quote layers rather than being half-eaten, and the pass stays a fixed point, because what it writes back can never be stripped a second time. Declining to unquote does NOT decline the no-break-space substitution: the two decisions are independent, and a label that keeps its quotes bleeds─through its spaces exactly like any other. - Styling and layout directives → dropped.
style,classDef,class,linkStyle, a standalonedirection(the one written inside asubgraph), and the interaction directivesclick/href/callback. Each described how the diagram should look or behave, which the ASCII renderer cannot express, and each drew a box named after the whole line: the vendoredclassDefpattern is anchored at column 0 so an indented one never matched, and none of the others match any pattern at all. This was the largest single defect in the corpus —stylein 36 fences,classDefin 33,classin 17,directionin 16,linkStylein 7; 249 dropped lines in total. Dropping everyclassDefalso closes a crash:parseStyleClasssplits each declaration on:and indexes the second field blindly, so a column-0classDef x stroke-dasharray: 5 5panicked the vendored parser and took the whole fence down to the verbatim fallback. The one thing given up with it is label-text color, which a column-0classDefgives to nodes carrying a:::classNamesuffix — no corpus fence does that, and keeping only the column-0 spelling would make the pass's output depend on indentation.
A node named after one of those keywords is NOT a directive and is never dropped:
style-review --> x, classDefault --> y, direction --> up, class[Class registry] --> B.
The rule is the shared mermaidDirectiveShape discriminator (see mdpreview_transpile.go),
reached through flowchartDirective, which masks labels first (so a keyword id carrying a label
cannot look like a keyword followed by arguments) and runs the link rewrite first (so the
discriminator's arrow test sees a --> whatever link form the author wrote — it does not know
=== or -.->). That discriminator is the one place this decision is made for all three
diagram types; do not add a keyword test anywhere else.
Everything else is copied through unchanged: subgraph/end blocks, accTitle/accDescr
(unrenderable too, but they carry author prose rather than presentation, so removing them would
delete text), <br/> in labels, chained a --> b --> c, %% comments (whole-line and inline
tails), and node ids. Line order and indentation are preserved, the line count is preserved
except for the dropped directives, and the pass is a fixed point on already-well-formed source.
When dropping would leave a fence with no statement at all, the original source is returned
instead, so this pass can never be the reason a fence disappears from the preview.
It is a text-level pass, not a rebuild through flowchartBuilder, and that is deliberate.
41% of these fences use subgraph, which the builder has no concept of — re-emitting through it
would drop every grouping box and replace author node ids with synthetic n0, n1, ... ids that a
subgraph body cannot reference. Fixing one fifth of the corpus by regressing two fifths is not
a trade worth making.
Residual after the pass: 2 of 138 fences, down from 76, still draw a stray box — both from
the ~~~ invisible link, which is deliberately not normalized. It exists purely for layout, and
the renderer has no invisible edge, so turning it into --> would draw a relationship the author
explicitly hid. Two smaller leaks are known and accepted: an accTitle/accDescr line would
still draw a box named after itself (no corpus fence has one), and mermaid's %%{init: ...}%%
theme block is read as an ordinary %% comment and simply ignored.
When a flowchart contains multiple independent subgraph blocks, the vendored ASCII renderer
does not lay them out separately — it builds a single node grid and attempts to draw a rectangle
around the cells each subgraph occupies, resulting in overlapping borders and nodes drawn in the
wrong boxes. To fix this, diagrams that meet the split criteria are rendered once per subgraph,
with each result stacked under its own title and rule line (see renderMermaidSource in
mdpreview_transpile.go and the split logic in mdpreview_subgraph.go).
The split fires on structure alone, not on a detected defect. Every fence meeting the five conditions below is stacked, including the ones the combined render would have laid out correctly. That is the wanted output, not a side effect: separately titled blocks are what a reader asked for, and each block is narrower than the combined render, so a wide diagram becomes readable. Gating on an actual overlap was considered and rejected — it would mean rendering every fence twice just to decide, and it would make the output depend on the vendored renderer's current grid heuristics.
All of the following conditions must hold for a split to be safe. If any one fails, the fence renders exactly as it does today:
- The diagram is a
graphor aflowchart. No other diagram kind has subgraphs. - There are at least two top-level subgraphs. With one subgraph the renderer draws a single rectangle and nothing overlaps.
- No subgraph is nested inside another. Nested subgraphs would fragment during the split and lose their grouping.
- Nothing but the header, comments, blank lines and layout directives (
direction,style,classDef, etc.) sits outside a subgraph. A node declared outside would be dropped by splitting. - No node id is mentioned by more than one subgraph. This catches edges crossing from one
subgraph to another, and also nodes that two subgraphs both reference. Both cases would either
lose an edge or silently duplicate a box; a single rule covers both and is easy to check. A
subgraph's OWN id counts as a node id here, because an edge may point straight at a whole
block (
A --> groupBis valid mermaid) — without that, the pair looks disjoint and the split draws a stray box literally named after the other subgraph.
Rule 5 only works if both sides read an id the same way. A block's own id is claimed as one
whole string, so the walk over the bodies has to produce that same string. What may be in an id
is mermaidIdentRune, the rule the transpiler already uses to find where a directive keyword
ends: letters, digits, _, and the three punctuation characters real diagrams write inside
names — -, . and /. Each of those three stays inside the id only when another identifier
rune follows, which keeps after-state, svc.a and after/state whole while still ending the
id at the - of A-->B. The walk reads runes, not bytes, so a Cyrillic id comes back whole and
a non-Latin punctuation mark between two ids (A·B, an em dash) ends the first one instead of
gluing the pair into a string that matches neither half. A :::className suffix is skipped up to
the end of the class name, not up to the next space, so a statement written tight against it
(A:::hot-->B) still shows every id after the suffix.
Three malformed shapes refuse as well, and none follows from the five conditions: an unterminated
subgraph (no closing end), a stray end with nothing open, and a subgraph or end line
carrying a second statement behind a ; separator (end; B2 --> A1). Mermaid accepts ; as a
statement separator, and the structural dispatch reads such a line as the keyword alone, so the
tail would belong to no block and rule 5 would never see the node ids in it — a fence whose only
crossing edge is written that way would split and lose it. The vendored renderer does not split
statements on ; either, so the single-render fallback these lines drop to is exactly as good as
it was. A ; inside a label, and a trailing ; with nothing after it, are not separators.
Rendered art is spliced into the document after glamour has run, bypassing glamour entirely. C0 control bytes and DEL are dropped from it (newline and tab kept) before it is spliced, so an ESC written into a node label — or sitting in the fence text used as the verbatim fallback — cannot reach the terminal as a live escape sequence. Box-drawing glyphs are multi-byte UTF-8 and are never touched by that filter. This filter is scoped to the mermaid art only, not the whole document — see "Known limitations" below for what is still unfiltered.
When any condition fails, the fence falls back to a single render of the whole source — today's behavior. The fallback is all-or-nothing: a part that cannot render, renders blank, or panics is caught, and the whole fence is re-rendered as one unmodified piece through the existing single-render path.
Measured over a corpus of 300 mermaid fences in the author's document set: 89 use subgraph, of
which 23 have an edge crossing between two subgraphs and 30 nest subgraphs. A meaningful share
keeps today's behavior, so the fallback is a correctness guarantee, not an edge case.
These are accepted, documented gaps in the preview mode — not bugs to fix under patch discipline.
-
Wide mermaid diagrams are clipped in preview; widen the terminal to see them.FIXED — the left/right arrows now pan the preview (scroll_left/scroll_right, see the "Horizontal panning wiring" hunk map above). The art is still never re-wrapped or truncated to fit — that anti-reflow design inrenderMarkdownDocumentis unchanged — but the render is now cut to a visible column window atm.layout.scrollX, with the same«/»overflow indicators the diff pane uses, so every column of a wide diagram is reachable. Measured on a real 206-cellflowchart TDat a 120-column pane: the third subgraph starts past column 120 and is unreadable at offset 0, and the pan clamps at offset 86, which puts the diagram's last column at the right edge. Those numbers predate subgraph splitting and describe a fence the split declines. A three-subgraph fence that meets the five split conditions is now drawn as three stacked blocks, each only as wide as its own nodes, so it no longer needs panning at all; re-measure against a declined fence before quoting a width again. What remains true for any render still wider than the pane: only whole columns move, so a box straddling the edge is still cut mid-glyph until you pan past it, and prose (already wrapped to the pane by glamour) goes blank once you pan past its end. -
Spaces inside an edge label render asFIXED — every edge-label space is now a no-break space, not a plain one (see─(dash) on the arrow line.docs/plans/completed/20260805-mermaid-edge-label-rendering.mdandmdpreview_nbsp.go's doc comment). U+00A0 is not the byte" ", somergeDrawingsin the vendored renderer treats the cell as opaque and keeps it instead of letting the arrow line or a crossing edge bleed through, while terminals still draw it as a blank. Four costs are accepted in exchange: art copied out of the terminal carries no-break spaces rather than plain ones; a small number of fonts render U+00A0 visibly instead of blank; on the plain-flowchart path a run of spaces inside a label collapses to one, so deliberate spacing is lost; and on that same path the label is one byte per space wider than before, because the vendored renderer reserves an edge label's column width withlen()and U+00A0 is 2 bytes where a space is 1 (A -->|"read as fallback"| Bcomes out 23 columns against 21 forread_as_fallback, and is drawn a column left of true centre). The transpiled path pays neither of the last two — it already substituted a 2-byte·, andmermaidSafeTexthad already collapsed its whitespace runs. Measured across the corpus (see "Corpus verification result" under Progress Tracking in the plan above): the substitution changed the rendered art of 83 of 231 fences. -
A decision node with three or more labeled out-edges can still put two labels where the reader cannot tell them apart, when the diagram's direction cannot be flipped, the flip doesn't help, or the flip would blow a diagram that fitted the pane up to more than three times its width. The complete fix is in the vendored
drawTextOnLine(reserve occupied cells, nudge the label along its line), which would mean forkingmermaid-ascii. Instead,renderMermaidSourcerenders once, counts collisions withmermaidCollisionCount(mdpreview_collision.go), and keeps a second render with the direction flipped toLRonly when ALL of: the source has a headermermaidFlipDirectionToLRaccepts —TD,TB, or a bareflowchart/graphwith no direction at all, which the renderer lays out top-down and which the flip fixes by inserting the keyword (RL/BT/LRare left alone, and so is a missing or malformed header); the first render collides at least once; the flipped render succeeds and is non-blank; the flip does not blow a diagram that fittedpaneWidthup to more thanmermaidFlipBlowupRatio(3.0) times its width (mermaidFlipWidthAcceptable, whose rule in columns ismermaidWidthTradeAcceptable); the flipped render collides STRICTLY FEWER times than the first. The header is tested before the collision count, so the label scan never runs on a source whose|...|is not an edge label at all. Any gate failure keeps the first render, so a fence with no collisions never renders twice and every failure path degrades to today's output — including a panic in the second render, whichmermaidRetryLRIfCollidingcatches itself rather than letting it reachrenderMermaidBlock's outer recover, whose fallback is the fence's raw source text. The detector counts two collision shapes, and needs both: two surviving labels crowded into one corridor, and one label painted over another so neither survives as a word (collectionoversingle compositeleavessincollectionite). The crowded shape is flagged when two distinct labels on one row are closer than the smaller of either label's own length and 8 columns — a label's own length is in the threshold becausenoandyesfive columns apart read as separate words whilecollectionandsingle compositefour columns apart do not. The overwriting shape is flagged when a matched label has leftover letters against it that spell part of a different label. A match is otherwise dropped when it overlaps a longer label's span or butts against text unrelated to any label — but that drop is not reliable: see the next entry for the measured case where ordinary node text is caught anyway, because "unrelated to any label" is checked against every OTHER label's text, not against whether the row has an edge label on it at all. Why the width guard is a ratio and not a fit test. It used to decline any flip that took a diagram fittingpaneWidthand made it not fit, and that guarded the wrong thing. The reader can pan (scroll_left/scroll_rightare both onmdPreviewAllowedActions), so a wider diagram costs keystrokes, while a diagram whose labels are painted over each other cannot be read at any pane width and panning does not bring it back. The guard exists only to bound the damage when the DETECTOR is wrong — a phantom collision sending a readable diagram off to several times its size for no gain — so it has to trigger on absurdity, not on width. The fit-only form suppressed the feature's headline fix inside a window of pane widths, which is what the ratio replaces it for. Measured ontestdata/mermaid/collision-fitting-td-render.mmd(the section-1 fence of the author's sample plan) on 2026-08-05:pane width kept art width is the label intact? 80, 120 238 (flip kept) yes 160, 200 150 (first render kept) NO — the row reads ├◄───collectioningle composite──────────────┤:collectionpainted oversingle composite, thesdestroyed240, 300, 400 238 (flip kept) yes At 160 and 200 the top-down render is 150 columns, so it FITS, so the old guard declined the flip and left the wrecked art on screen; below 160 the first render did not fit and above 200 the flip did, so both ends were fine and only the middle was broken. That fence widens by 238/150 = 1.6x, while the phantom-collision blowup the guard was added for widens by 330/71 = 4.6x, so 3.0 separates them with more than a factor of margin on each side (
TestMermaidFlipBlowupRatio_SeparatesTheMeasuredCasesfails if a later edit moves the constant near either). Measured across the corpus (15254 markdown files, 233 distinct fences — the list is a live snapshot of the author's disk, 231 when the retry shipped — with the old fit-only gate and the ratio gate run side by side in one binary, same detector over both outputs, on 2026-08-05): at pane width 120, 6 fences still carried a colliding label under the fit-only gate and 3 do under the ratio gate; 3 fences change their kept render, all of them from the first render to the flip: 103 → 150 columns (1.46x, 2 collisions → 0), 100 → 121 (1.21x, 1 → 0) and 59 → 170 (2.88x, 1 → 0). Each was checked by eye and each is a real collision, not a phantom: the three first renders carry├◄ddecision==abortrove,├◄──materializeetedandan agent acknowledges───────Publish Selected. No fence in the corpus widens past the ratio as a result of this change — by construction it cannot, since the ratio is the gate. At pane width 160 the same run gives 8 colliding under the fit-only gate against 3 under the ratio gate, with 5 fences changing their kept render (the three above plus 151 → 260 and 142 → 173). The 3 left at either width are 2 that take the subgraph-stacked path, which returns before the retry, and one 115 → 328 fence whose flip collides just as often as its first render, so the strictflippedCount < firstCountcomparison declines it and the width guard never decides it at all. (An earlier note here counted that fence among "4 declined by the width guard"; that was true of the gate ORDER — the width gate runs first — but only 3 of those 4 were actually held back by width.) Widths on the flips that were already kept before this change, at pane width 120: 137 → 79, 135 → 111, 121 → 112, 155 → 159, 57 → 86, 151 → 260, 148 → 279, 207 → 281 (the repo's owncollision-three-branches.mmdfixture) and 136 → 286. A fence already writtenLRthat still collides is not helped — there is no further direction to try. The cost of a fence that DOES collide is two full renders every time it is drawn, and preview has no render cache by design (seerenderMarkdownPreview's doc comment), so a pan keypress across such a document pays both. -
The collision detector itself is inaccurate in both directions, and this was found and accepted before shipping, across three review rounds that each reproduced it by running the real functions — not discovered later. It misses real collisions and it invents ones that are not there.
It misses short labels.
mermaidLabelFragmentMinRunes = 3treats a leftover overwrite run of two runes or fewer as ordinary node text, not as wreckage, so a short label painted cleanly over another counts zero. A realflowchart TDwith branch labelsyes/no/holdrenders the row│ Is it ready? ├─holdo─┬───┐—holdpainted overno, leaving only theo— andmermaidCollisionCountreturns 0 for it, so the retry never fires, even though the LR render of that same fence is both collision-free and narrower (30 columns against 43): a strictly better render, silently discarded. Reproduced the same way onaa/bb/cc→├─ccb─┬──┐,aaa/bbb/ccc→├─cccbb┬──┐, andaa/bb/cc/dd→├─ddb─┬──┐. A leftover run of 0, 1 or 2 runes counts zero every time; one of 3 or more counts one.It also misses multi-word labels — exactly the labels the no-break-space half of this change exists for.
mermaidNeighborRunstops at the first non-word rune, and a multi-word label's own no-break spaces ARE non-word runes, so an overwrite of a multi-word label breaks into runs of 0-2 letters on both sides and is indistinguishable from an undamaged draw.yespainted overa b c dat all five possible offsets (yes c d,ayesc d,a yes d,a byesd,a b yes) counts zero at every one.In the other direction, it invents collisions on rows that carry no edge label at all.
mermaidClassifyHitcalls leftover letters an overwrite whenever they happen to be a substring of some OTHER edge label anywhere in the fence, with no check that the row it is looking at is an edge-label row in the first place. With labelsnoandnothing to dopresent in one fence, the plain art row│ Queue drained: nothing pending │counts 1:nomatches insidenothing, and the leftoverthinghappens to be a substring ofnothing to do. A 10-node diagram with one repeated word reported 9 collisions with zero real ones.It is also inconsistent by construction:
mermaidCollisionLimit'smin(len(a), len(b), 8)makes detection sensitivity scale with label length, roughly 8x between the shortest and longest labels measured. Smallest gap still reported CLEAN, measured pair by pair:a/bat 1 column apart,no/yesat 2,add/delat 3,open/closeat 4 — against 8 columns for thecollection/single compositepair the cap was built for. Two 2-character branch labels two columns apart in one arrow corridor (├◄──no──yes──┤) are reported collision-free.Below the reporting bar but worth recording alongside these:
mermaidPipedEdgeLabel's character class excludes", so a quote-carrying label such as|"a" and "b"|cannot match and is dropped from the label set entirely — if a fence has exactly two labels and one of them is quote-carrying, collision detection is disabled for that whole fence.Blast radius. A missed collision is not a regression — it leaves today's output exactly as it was before this feature existed. A phantom collision costs one extra vendored render (about 9ms, see the corpus note above) and can only ever fire on a fence the detector itself believes collides — a clean fence's bytes never change. Even a wrongly-triggered flip is still bounded by
mermaidFlipWidthAcceptable: it cannot blow a fence that fit the pane up to more than three times its width, the same guard that bounds a correctly-triggered one. A phantom collision can now cost a fence that fit the pane its fit, up to that ratio — that is the deliberate price of the ratio rule, paid so a real collision inside the same window is fixed rather than left wrecked. What a phantom collision CAN do is win the strictflippedCount < firstCountcomparison on noise rather than on a real fix, since that comparison is made on counts that can themselves be wrong in either direction.Direction for a real fix. The detector's premise is "find each label in the art and measure the gap between hits," and that premise is what produces errors in both directions at once: the gap measurement is unreliable for short and multi-word labels, and matching a leftover run against any label's text — rather than against evidence that a specific label was actually destroyed — is what lets ordinary node text pass as a collision. Better evidence is that a specific SOURCE label does not appear intact anywhere in the art: that test cannot fire on node text at all, because node text was never a source label to begin with, and it does not depend on how long the label is or how many words it has.
-
Long node labels are wrapped, but only on a fence whose art already overflows the pane. A box is as wide as its widest label line, so one long label drags the whole drawing past the pane and the reader has to pan to read a diagram that would otherwise fit. The renderer already honours
<br/>inside a node label (newGraphLabelin the vendoredlabel.gosplits on it), somermaidNarrowIfOverflowing(mdpreview_wrap.go) re-renders the source with long node labels broken over several lines. It runs LAST, after the LR retry, and wraps the source in whichever direction that retry kept — the retry is a correctness pass (labels painted over each other cannot be read at any width), the wrap only a readability one (a wide diagram is readable, it just costs panning), so the wrap must never be able to undo a flip.Why the overflow gate. Unlike the LR retry, which only fires on a fence the detector already believes is broken, wrapping would otherwise change every diagram carrying a long label, including the great majority that render perfectly today. So the art is measured first and a fence that FITS
paneWidthreturns byte-identical, without a second render (TestRenderMermaidSource_FittingFenceIsByteIdenticalToTheUnwrappedRender). Past that gate, each rung ofmermaidWrapTargets— a quarter of the pane, then an eighth, both clamped into 16..34 runes — is rendered widest-first, and a rung's render is kept only when it is STRICTLY narrower than the art it replaces and does not collide MORE times (samemermaidCollisionCountthe retry is gated on). The first rung that fits the pane wins outright, so the fewest labels are broken; if none fits, the narrowest candidate is kept, since the reader still pans across fewer columns. Two rungs is the whole ladder because each costs a render.What is never wrapped: a label that already contains an author line break (
<br>in any spelling, a literal\n, a real newline) — that is the author's own line breaking; an EDGE label — the renderer does not honour<br/>there, it prints the five characters literally, and the walk consumes each arrow together with its|label|before any byte can be read as a node shape; a single word, however long — a broken identifier or path reads worse than a wide box; a subgraph header or anaccTitle/accDescr; and any source whose diagram kind is notgraph/flowchart. Breaks are taken on ASCII space and tab only, never on U+00A0, so an edge label that somehow reached the wrapper could not be broken anyway (strings.Fieldswould split on U+00A0, which is why the split uses its own predicate).Measured on
testdata/mermaid/collision-three-branches.mmdat pane 160, where the LR retry has already won and so LR is the direction wrapped. The same target buys back very different width in the two directions, which is why the targets are a ladder rather than one number:target TD width LR width none 207 281 40 157 229 34 136 212 28 122 188 22 106 164 20 101 150 16 90 135 In production that fixture goes 281 → 150 columns at pane 160 (the second rung, 20, wins; the first rung's 212 is kept as the running best until it does), 25 → 39 rows, 0 collisions before and after. Height roughly doubles, and that is the intended trade: the preview scrolls vertically for free while sideways it moves one column per arrow-key press. The renderer adds a blank row between label lines (
graphLabelLineGap), so a two-line label costs three rows.Measured across the corpus (15254 markdown files, 234 distinct fences, pane width 120, base
681f053against this change, 2026-08-05): 43 of 234 fences change, all 43 narrower, 0 wider; width delta min 2, median 22, max 146, mean 37.8 columns; 14 fences that overflowed the 120-column pane now fit inside it. 0 fences gain a collision — the number the feature ships or does not ship on. 0 panics, 0 timeouts, 0 blank renders and the same 24 unsupported-diagram-type errors on both builds, and no word visible in the base art disappears from the new art on any of the 43. Of the 102 fences that overflow the pane on the base build, 44 have no wrappable label at all (short labels, or the author's own<br/>already), 14 produce no rung narrower than what they had, and 44 keep a wrapped render — 29 of those on the second rung. The collision veto is not dead code: on one fence the first rung narrowed 277 → 198 columns while crammingnothing left: whole-list writeandindex or '-': item add/remove/reorderinto one corridor, and was declined for it.The cost is up to two extra vendored renders per overflowing fence, every time the fence is drawn — preview has no render cache by design (see
renderMarkdownPreview's doc comment), so a pan keypress across such a document pays them again. A fitting fence pays nothing.Limitations. A wrap cannot fix a diagram whose width comes from its node COUNT rather than its label lengths: 14 corpus fences overflow with labels short enough that no rung is narrower. A fence whose labels are all single long words (an identifier, a path, a URL) is declined outright. The floor of 16 runes is a real floor — on a very narrow pane the art still overflows, and the pass keeps the narrowest candidate rather than promising a fit. And the height cost is unbounded in principle: a diagram with many long labels can grow past a screenful, which is cheap to scroll but does mean the whole diagram is no longer visible at once.
-
TOC active-section highlight is stale during preview — see the
app/ui/view.gonote under "Review phase 4 wiring" above. -
You must leave preview before changing file. Preview now works in a multi-file review, but no file-changing key is on the allowlist:
n/p(next_item/prev_item), tree focus, and tree navigation all stay blocked, and a mouse click in either pane is read-only while previewing. So reading two markdown files in one review isP, read,P,n,P. The mode itself survives a file switch correctly (it stays on for another full-context markdown, andhandleFileLoadedswitches it off for anything else) — there is simply no key that performs the switch from inside preview. Allowingn/pwas considered and rejected: with a search still live (matches survive into preview until esc or the next file load) they navigate search matches instead of files, which writesm.nav.diffCursorand jumps the viewport in diff-line coordinates the preview render does not have. See the comment abovemdPreviewAllowedActions. -
Most transpiled classDiagram/stateDiagram-v2 fences are wider than an 80-column pane, and the adaptive label cap only reduces that — it does not prevent it. Re-measured 2026-07-29 over every class/state mermaid fence in the author's document corpus (18 fences), rendered at a pane width of 80: widths
36, 50, 59, 73, 81, 84, 92, 101, 101, 102, 103, 106, 109, 125, 137, 144, 182, 210. Four of eighteen fit; the median is 102.These numbers replace an earlier, worse set (
36, 79, 80, 81, 82, 88, 96, 101, 102, 109, 113, 144, 187, 243, 284over 15 fences, three fitting, median 101 — the run also deduplicated identical fences, hence 15 rather than 18). The improvement came from makingflowchartBuilder.declarationOrdera real topological order: the old "edge sources first" rule made every intermediate node a layout root, so multi-level hierarchies were drawn flattened into fewer, much wider rows. The widest corpus diagram went from 284 cells to 210, and one fence came back inside the pane.levels()now replays the renderer's own placement rule as well, so the predictedkmatches the real layout on all 18 fences (it disagreed on 11 of 18 before).The per-line label cap (
mermaidLabelCapinmdpreview_transpile.go) shrinks label lines as the number of nodes on the widest layout level (k) grows, and it is worth keeping, but it cannot guarantee a fit. It floors at 16 runes per line, so fromk >= 3up it is already on the floor with nothing left to give, and thelabelJogterm was calibrated on a fixed 10-character relation word while a stateDiagram transition label is free text that reserveslen(label)+3columns of its own. Overflow therefore happens at everyk, not only atk >= 4— an earlier version of this note and of the plan both claimed ak >= 4boundary, which the corpus measurement disproves.The overflow itself is no longer a dead end: horizontal panning (the item above) reaches the clipped part, so a too-wide transpiled diagram is now readable on a narrow pane, just not in one screenful. The cap is still worth keeping — fewer pan presses — but it cannot promise a fit. Two tests pin the honest behaviour on verbatim corpus fences so the claim cannot quietly drift back:
TestRenderMermaidSource_RealCorpusStateDiagram_TopologicalOrderBringsArtInsidePane(the fence the topological order rescued, 88 cells before and 73 now) andTestRenderMermaidSource_RealCorpusClassDiagram_FloorCapStillOverflowsPane(still 81 cells with the cap on its floor). -
A node with several outgoing labelled edges only gets ONE of those labels drawn, and past about four targets the labels merge into a token that is in none of them. This is the vendored
mermaid-asciirenderer, not the transpiler: the transpiler emits every label it parsed, but the renderer routes all of a node's outgoing edges along one shared horizontal row and writes labels onto that single row. Measured over the 18-fence corpus at a pane width of 80: 10 of 18 fences lose at least one edge label, worst case 4 of 6 lost on one fence. At six outgoing edges the six labelscontains / needs / emits / uses / sends / pollsrender ascontainsplus the nonsense tokenpollss.Note this is fan-out to DIFFERENT targets, not several parallel edges between one pair — an earlier version of the plan's Failure-modes table described only the parallel-edge case, which is much rarer. It is also independent of the width limitation above: one fence loses a label at column ~90 while its art is 144 cells wide, so a wider terminal does not bring the label back.
Fixing it means changing the vendored renderer's label placement, which is out of scope for this patch. Two tests pin the current behaviour so a vendor bump that changes it cannot pass silently:
TestRenderMermaidSource_FanOutToDifferentTargets_VendoredRendererDropsEdgeLabelsandTestRenderMermaidSource_WideFanOut_VendoredRendererMergesEdgeLabelsIntoOneToken. -
When the label cap is on its floor, two different labels or titles can truncate to the same string, so the diagram reads as ambiguous rather than merely short. Measured over the 18-fence corpus: 7 fences contain at least one such collision, and every one of them sits at the 16-rune floor. Three real examples:
- three transitions out of one state —
reviewer resolves request-changes,reviewer resolves approve,reviewer resolves reject (hard)— all render asreviewer·res...; - two different conditions,
decision==approve AND publicationDate in futureanddecision==approve publicationDate ≤ now, both render asdecision==app...; - two classes,
SimpleWorkflowEngineandSimpleWorkflowManager, become two boxes both titledSimpleWorkflo....
In all three the cap did not buy a fit anyway (those fences measure 109, 101 and 182 cells against an 80-column pane), so the readability is spent for nothing. There is no cheap fix. The only lever is the floor, and raising it is not free: at a floor of 24 runes the collisions drop from 7 fences to 3, but the corpus median width goes from 102 to 124 cells, the widest from 210 to 257, and one fence that currently fits stops fitting. Horizontal panning has since landed, which changes the trade-off — a wider diagram is now readable, it just takes pan presses — so raising the floor is a live option rather than a blocked one. It is not done here: it is a separate change with its own re-measurement over the corpus. The other candidate fix, a disambiguating suffix on colliding labels, still needs its own plan. The truncation does at least always keep its
...ellipsis, so a truncated label is never mistaken for a complete one.A related, separate case: some collisions do not come from the width cap at all.
resolve {outcome}andresolve {outcome} (act without claiming)both render asresolvebecausemermaidCutParentheticaland the brace cut are content rules that run regardless of width. That shortening is deliberate (see the plan's "Edge label" section) and is not affected by the cap. - three transitions out of one state —
-
⚠️ --no-colorsnever aligns the preview annotation source map — the whole feature is absent in that mode, not degraded at the edges. Measured 0 of 59 real documents (the task 3 spike corpus). With colors off, glamour's ASCII style writes a heading's marker on the row AFTER its text as well as on it, and a task item's marker reappears on the next list row, so the marker sequence stops being one-per-block andmdPreviewAlignRowsrefuses every document it sees. In that mode preview renders exactly as it did before this feature existed: read-only, no highlight, no painted annotations,a/click do nothing. This is a whole-mode gap, not an edge case — it is the single most important limitation to know about before assuming annotate-in-preview works everywherePdoes. -
A table is one annotation target, not one per row. One real corpus document produced 561 markers for 6 tables, 73 landing mid-row; a comment on a table means "this table", not "this row" — see
mdPreviewAlignRows'takeTableinmdpreview_srcmap.go. A consequence: two tables separated by nothing but a blank line produce one indistinguishable run of table markers, so that document fails alignment and degrades entirely rather than mis-anchoring one table's comment onto the other (TestMdPreviewSrcMap_AdjacentTablesDegrade). -
A blockquote is one annotation target too — its inner paragraphs get no separate anchors. This was a task 2 deviation from the plan's own granularity list, which did not call this out explicitly; it follows the same "one target per swallowing construct" treatment the spike established for tables, and keeps the target list a clean partition of the document (see the task 2/task 3 decision log entries on
mdPreviewQuoteInfos, formerlymdPreviewQuoteParagraphs).A quote whose direct children are ALL boundary kinds gets no target instead of one, and that is not a limitation but the reason the fold works at all: its nested blocks are the targets.
> - a bulletanchors the bullet,> ## hanchors the heading, and so on for a fence, a table, a rule or another quote.swallowedSpanfinds no span to claim for the quote itself, so emitting a target would put the quote and its first nested block on the same source line — two blocks sharing oneannotation.Store(Line, Type)key, whichAddresolves by REPLACING, silently destroying one reader's comment. glamour still writes the quote's chrome and so still marks it, andmdAlignState.skipTargetlessQuotesis what accounts for that marker: it drops aBlockQuote.Prefixhit only when the next unaccounted entry ofmdPreviewQuoteInfossays that quote produced no target. Both conditions have to hold, so a quote marker turning up where a quote with a target was expected still fails alignment rather than being swallowed.⚠️ This used to degrade the whole document — one unclaimed marker, and every block in the file became unanchorable. It is what the user hit as "sometimes I don't get annotation capabilities in preview mode, seems a bit random": a single> - bulletline anywhere in a 118-line plan was enough.TestMdPreviewSrcMap_QuoteWithoutOwnTargetAlignspins every shape. -
Anchoring is block granularity, not character-exact. A comment on a wrapped paragraph anchors to the whole paragraph, not the line or word under the cursor at the moment of wrapping — matching how the same paragraph would be commented in source view before this feature existed, where the paragraph's block boundary is already the finest resolution.
-
Two annotation actions are still blocked in preview:
@and}/{. Creating, editing, reading, deleting, and flushing annotations all work inside preview; listing them in the@popup and stepping between them still require leaving preview first (pressP, act, pressPagain). Neither is onmdPreviewAllowedActions' allowlist — see that map and its doc comment inmdpreview.gofor the per-action reason.dused to be the third entry here, which made a comment created in preview impossible to undo from inside preview; it is now allowed, because an annotation is a cursor stop of its own (see "Preview cursor" above). -
Clearing an annotation's input does not delete it — that is what
dis for.aon an annotation pre-fills the existing comment, but clearing the input and confirming runscancelAnnotation, which leaves the stored comment untouched. This is the diff pane's own behavior, unchanged. -
A thematic break (
---) has no position of its own and is recovered by a gap scan. goldmark appends nothing to aThematicBreak'sLines(), somdBreakResolver(mdpreview_blocks.go) bounds it between the nearest siblings that DO carry a position and takes the first line in that gap which looks like a rule (looksLikeThematicBreak— three or more matching-/*/_, ignoring leading whitespace and any>quote markers). The candidate test is what makes the gap scan correct on ordinary documents rather than only on isolated breaks:Lines()on a fenced code block covers the CONTENT only, so a---after a fence has the closing ``` inside its gap, and a break inside a blockquote has a bare>continuation in its. The earlier "first non-blank line" rule anchored to those, with `Aligned=true` — a wrong source line in the `-o` output and nothing to warn on. Resolved lines memoize per node, because a run of consecutive breaks resolves each break through the one before it (800 consecutive breaks took seconds without the memo, on the bubbletea `Update` goroutine). A break whose gap holds no rule-looking line resolves to nothing, which fails alignment and degrades the whole document — the correct outcome, since the alternative is anchoring to a line that is not the break. -
A click inside an expanded block, on a row that carries no line anchor, collapses the expansion.
mdPreviewClickDiffresolves a clicked row to a raw source line by exact row equality (srcMap.lineAt). Two kinds of row inside an expanded block have no anchor to find: a blank source line, which paints a row but never gets one because the highlight skips blank rows, and a comment row spliced between the raw lines. Both fall through toanchorAtRow, which resolves to the block — and a block placement writes a fresh cursor state, so the block collapses under the click. Accepted rather than fixed: keeping the fallback unchanged is what makes a click behave the same everywhere else in preview, and the reader can re-expand withr. -
Expanding a container shows only its own source, up to the first nested block. A list item or blockquote holding a fenced code block, a nested list, a table or a heading has its raw span cut at that block's first source line (
mdPreviewClipRawSpan, see "Raw source expansion" above), so pressingron the item shows the lines before the nested block and stops. The lines after it belong to the nested block — expanding THAT shows them, because they are rendered into rows it owns. The alternative — painting the container's whole span — put the nested block's content on screen twice, as source and as render, which is why this is the cut rather than the bug. -
A container's trailing lines are unreachable when its nested block is a code fence. A fence refuses expansion (
mdPreviewExpandRefusal: it already shows its source), and it is the block whose rows the container's trailing paragraph is rendered into. So in- para a/ fence /para b, thepara bline has no raw-line stop in preview and cannot be annotated per line —aon the fence block anchors to the fence instead. Accepted: lifting it means expanding a fence into its own source, which is the one thing the fence refusal exists to prevent. The line is annotatable in the ordinary source view with preview off.This used to be stated for a mermaid diagram as well, and that half is gone — measured on
- para a/ mermaid fence /para b, which produces three blocks (the item, the diagram, andpara bas a paragraph of its own). Two separate reasons, either one sufficient: the diagram now expands rather than refusing, and the placeholder that replaces the fence is written at column 0, so it breaks the item apart and the trailing paragraph gets its own anchor instead of being rendered into the diagram's rows. The second reason held before this change too — the entry was wrong about mermaid when it was written, not made wrong by it. -
A list item whose children are all boundary kinds contributes no target, and degrades the whole document.
swallowedSpanaggregates only over a container's own direct content, and a nested list, table, code block, heading or thematic break is excluded because it gets its own target — so an item like-followed only by an indented sub-list (-\n - deep) yields no target of its own while glamour still writes its item marker. The length mismatch fails alignment, and the document degrades to read-only in full, the same all-or-nothing way the adjacent-tables case does.The blockquote used to share this shape and no longer does — see the blockquote entry above and
skipTargetlessQuotes. The same treatment is not extended to the list item here: a quote's markers are already tracked one-for-one bymdPreviewQuoteInfos, so skipping one is exact, while items have no such per-construct record anditemis two thirds of every anchor the corpus produces, so a skip rule for them would be a guess over the most common marker in the sequence. -
A blockquote whose list comes BEFORE its own trailing paragraph degrades the document. In
> - a bullet/>/> trailing parathe quote does own direct content (the trailing paragraph) and so does get a target, butswallowedSpangives that target the paragraph's line — after the nested item's. The walk emits the quote on entering the quote, so the target sequence reads quote(line 5), item(line 3), andmdPreviewBuildSourceMaprefuses a source-line sequence that is not strictly increasing. The two axes genuinely conflict here and cannot both be satisfied: glamour writes the quote's chrome row BEFORE the item's row, so the quote anchor has to precede the item on the row axis while following it on the source axis. Giving the quote its opening line instead would collide with the item's line, which is the(Line, Type)key collision the strictly-increasing check exists to prevent. Put the quote's own prose first (> intro/>/> - a bullet) and the document anchors fully, each bullet included.
Preview annotations — corpus measurement (mdpreview_srcmap_corpus_test.go, env-gated via
REVDIFF_MDPREVIEW_SRCMAP_CORPUS, same corpus definition the task 1-3 spike used: every file
under docs/plans/completed/, every file directly under docs/, and every .md at the repo
root):
Measured against the current repo (58 documents, one more than the spike's 59-entry corpus minus its one synthetic handwritten fixture, at pane width 80, colors on):
| metric | result |
|---|---|
| documents aligned | 57 / 58 (98.3%) |
| unaligned | README.md (multiple tables among other constructs; not root-caused further — falls under the documented table/alignment-mismatch degrade above) |
| anchors produced across the 57 aligned documents | 9,851 total |
Per-kind anchor counts across those 9,851 (confirms per-item granularity at real-corpus scale, not
just in the unit-test fixtures — item alone accounts for two thirds of every anchor produced):
| kind | anchors |
|---|---|
| item | 6,755 |
| paragraph | 1,339 |
| h3 | 727 |
| h2 | 551 |
| enumeration | 199 |
| code_block | 153 |
| h1 | 57 |
| h4 | 28 |
| table | 34 |
| hr | 6 |
| block_quote | 2 |
Re-run with go test ./app/ui -run TestMdPreviewSrcMapCorpusAlignment -v REVDIFF_MDPREVIEW_SRCMAP_CORPUS=<path-to-a-file-listing-one-.md-path-per-line> — the numbers will
drift as the corpus (this repo's own docs) grows or changes; treat this table as a snapshot, not a
promise.
.md in the repo (80 documents, find "$PWD" -name '*.md' -not -path '*/vendor/*') skipTargetlessQuotes moved the rate not at all — 79/80
before and after, the same 12,102 anchors, README.md unaligned on both sides — because no
document here writes a list inside a blockquote. The shape is nonetheless common in plans written
elsewhere: a 106-line document built to match the user's real one (six sections, each a heading, a
paragraph, one quoted bullet or quoted ordered item, a fence and a plain list) went 0/1 to 1/1,
45 anchors. A corpus that does not contain a shape cannot measure a fix for it.
ansi.Strip equal, same row count, same per-row
ansi.StringWidth — and is not mermaid-related. This is why every visual-identity assertion in
this feature's tests (and any future test on this render path) checks ansi.Strip equality + row
count + per-row width, never raw byte equality: a byte-identity test on renderMarkdownPreview or
renderMarkdownDocument would have been flaky from its first run, independently of anything this
plan changed.
- A subgraph id containing punctuation outside the identifier set can hide a crossing edge from
the disjointness check. The identifier rule — letters, digits, underscore, and the three chars
real names use (
-./) — is applied when the block header claims its id as one string, and again when a body reference reads an id from the same source text. If they tokenize differently, the edge is missed, and a fence that should not split may split with the edge dropped. Real-world ids are alphanumeric, which is why this is left as a limitation rather than fixed. Example: a subgraph idA·B(containing the middle dot) is claimed whole by the header (A·B), but when that name appears in a crossing edge likeA·B --> X, the body walk stops at the·and reads onlyAas the target, losing the reference in the disjointness check. The split then proceeds and draws a stray box instead of refusing. A non-ASCII mark between two id tokens (a–bwith an en dash instead of a hyphen) or an ASCII shape (a+b,a:b) shows the same gap.
github.com/charmbracelet/glamourv1.0.0 (deliberately v1, not v2 — v2 needs a different lipgloss module path that would vendor two copies of lipgloss)github.com/AlexanderGrooff/mermaid-ascii@latest (imported as a library, entry pointcmd.RenderDiagram)
Together these two grew the vendor tree from 31 modules / ~19M (baseline, before this patch) to
71 modules / ~51M (after Task 3). Most of that growth is mermaid-ascii's cmd package, which
pulls in gin, cobra, and logrus as transitive dependencies of a library this patch only calls for
one function (cmd.RenderDiagram) — see the plan's Technical Details section for why that
tradeoff was accepted, and "Dependency cost" below for what it actually measures. Confirm the
actual counts after any rebase with du -sh vendor/; they will drift as upstream's own
dependencies change.
The patch imports github.com/AlexanderGrooff/mermaid-ascii/cmd for exactly one symbol,
RenderDiagram, from two call sites: renderMermaidSource in app/ui/mdpreview_transpile.go
(the whole-source render) and stackFlowchartSubgraphs in app/ui/mdpreview_subgraph.go (one
call per subgraph block). That package also contains web.go, which implements an HTTP server
for the upstream tool's own web mode. Go links a package as a whole, so importing cmd at all
drags in everything web.go needs, even though nothing in revdiff can ever reach it.
Measured on the md-preview branch (2026-07-29, go build ./app, no -s -w):
before the patch (master) |
after | |
|---|---|---|
| binary | 12 MB | 28 MB |
| vendor tree | 19 MB | 51 MB |
| vendored files added | — | 1609 files, ~693k lines |
vendor/github.com/AlexanderGrooff/mermaid-ascii/cmd/web.go is the sole importer of the whole
web stack: gin-gonic/gin, bytedance/sonic (a JIT with hand-written assembly), cloudwego/iasm and
base64x, google.golang.org/protobuf, go-playground/validator, bluemonday, gorilla/css,
golang.org/x/net and golang.org/x/crypto. Those directories alone account for ~21 MB of the 32 MB
vendor growth, and go tool nm finds 632 gin/sonic symbols linked into the built binary. web.go
also calls os/exec to run git describe, so a terminal diff viewer now compiles in an HTTP
server and a subprocess call it never invokes.
Nothing here is a correctness or security problem in normal use — the code is unreachable, no
listener is ever started. It is a size and supply-surface cost. It is recorded rather than fixed
because the fix is a change to the vendored dependency itself (a build tag on web.go, a trimmed
vendor copy, or a patched fork exposing the renderer without the cmd package), which needs its
own plan: it changes what go mod vendor regenerates, so it has to survive every future
go mod tidy && go mod vendor in the rebase procedure below. Until then, treat the 28 MB binary
as expected.
After every rebase, re-run go mod tidy && go mod vendor. go mod tidy prunes an unused
module requirement, so if the rebase's conflict resolution temporarily drops the only import of
either dependency, tidy will remove it from go.mod/go.sum until the import is restored —
this is expected go mod tidy behavior for this project (see the plan's Task 1
REV="$(git rev-parse --abbrev-ref HEAD)-$(git rev-parse --short=7 HEAD)-$(git log -1 --format=%ct HEAD | xargs -I{} date -u -r {} +%Y%m%dT%H%M%S)"
go build -ldflags "-X main.revision=$REV -s -w" -o .bin/revdiffm ./app
cp .bin/revdiffm ~/.local/bin/revdiffmThis mirrors the Makefile's build target (same ldflags, same REV computation) but writes
revdiffm instead of revdiff.$(BRANCH)/revdiff, so it never touches the brew-installed
revdiff at /opt/homebrew/bin/revdiff. Verify after install:
command -v revdiffm # should resolve to ~/.local/bin/revdiffm
revdiffm --version
revdiffm --dump-keys | grep 'toggle_preview' # confirms P is bound in this build
command -v revdiff # should still resolve to /opt/homebrew/bin/revdiff, unaffected
revdiff --versiongit fetch
git rebase <new-tag> # e.g. git rebase v1.12.0
# resolve conflicts — expect them in app/ui/diffview.go and app/ui/model.go first
go mod tidy && go mod vendor
make test
# rebuild + install per the command aboveIf go mod tidy removes glamour/mermaid-ascii from go.mod after conflict resolution, check
that app/ui/mdpreview.go still imports both — a conflict resolution that accidentally drops
an import will cause tidy to prune the dependency instead of erroring, which surfaces as a
build failure only once something else tries to use it.
Upstream added a jump_file action (open a file picker) bound to P by default. That
collides with this patch's own "P": ActionTogglePreview — go vet/the Go compiler catches
it immediately as a duplicate map key, so a rebase that pulls in jump_file will not build
until this is resolved.
Resolution (already applied on this branch, app/keymap/keymap.go's defaultBindings()):
P stays ActionTogglePreview (this patch's binding, and the muscle memory this whole file
documents), jump_file moves to ctrl+p instead of its upstream default. ctrl+p is
otherwise unbound and — per gotchas.md's file-picker note — is not a bare printable key, so it
correctly toggles the picker closed on a second press (upstream's own default P binding
could not do that; see README.md's file-picker paragraph on printable-key filtering, which
still describes upstream's P default accurately and is deliberately NOT edited to match this
fork, per the doc-split policy at the top of this file).
Following files carry test/behavior assertions tied to this specific key and need re-checking
on every rebase that touches jump_file's default: app/keymap/keymap_test.go
(TestActionJumpFile_DefaultBinding, TestActionJumpFile_RegistrationHelpAndDump,
TestActionJumpFile_CustomConfiguration), app/ui/filepicker_test.go
(TestModel_JumpFileOpensPickerAndLoadsSelection sends tea.KeyCtrlP;
TestModel_JumpFileKeyFiltersInsideOpenPicker explicitly rebinds P to ActionJumpFile to
still exercise the printable-key-filters-not-closes behavior upstream's own default used to
demonstrate), app/ui/search_test.go (TestModel_SearchPrompt_SwallowsTogglePreviewKey).
r was unbound upstream at the fork base — defaultBindings() had R for ActionReload and no
lowercase r — which is why this patch could take it for ActionToggleRaw (raw source expansion,
above). If a rebase brings an upstream action bound to r, the Go compiler catches it immediately
as a duplicate map key in defaultBindings(), exactly as it did for P / jump_file, so the
build fails rather than one binding silently winning.
Resolution: keep toggle_raw on r and move the upstream action to another key, unless
upstream's use is the more valuable one — in that case move toggle_raw instead. r is worth
defending: it is only reachable inside markdown preview mode, but it is the one key the raw
expansion feature has, and it pairs with P in muscle memory.
Following files carry test/behavior assertions tied to this specific key and need re-checking on
every rebase that touches an r binding: app/keymap/keymap_test.go
(TestActionToggleRaw_IsValid, TestActionToggleRaw_DefaultBinding,
TestActionToggleRaw_HelpEntry) and app/ui/mdpreview_expand_test.go
(TestMdPreviewToggleRaw_ThroughTheKeyPath sends the r key rather than dispatching the action
directly; the other TestMdPreviewToggleRaw_* tests call the handler and survive a rebinding).