Skip to content

Epic: viewport state in model coordinates, delete line-index compensation #1411

Description

@zipadoodlez

Priority: medium (removes a bug class; ends in a large net line reduction)
Area: crates/jcode-tui/src/tui/ui_viewport.rs, crates/jcode-tui/src/tui/app/navigation.rs, crates/jcode-tui/src/tui/app/state_ui_messages.rs, crates/jcode-tui/src/tui/ui.rs
Version: jcode v0.86.0 (c9062899b)
Links: bug #1412 (resize jump); phases ship as separate PRs, each linking this issue

Why this is one issue and not six

This is a single design decision, so it is filed as a single approach discussion.
The phases below ship as separate PRs, each of which links this issue, which is
what the contribution guide asks for on large changes. Per-phase tracking lives in
the PRs, not in six issue threads.

Status and roadmap to completion (2026-09-27)

Step Branch PR State
2 snap on resize epic/1411-phase-2 #1424 merged 2026-09-23
3 publish the frame epic/1411-phase-3 #1425 merged 2026-09-23
4 anchor and ContentPos epic/1411-phase-4 #1427 merged 2026-09-26
5a prompt-jump epic/1411-phase-5a-prompt-jump #1428 merged 2026-09-26, net -32 lines
5b bookmark epic/1411-phase-5b-bookmark #1429 open, MERGEABLE, rebased; findings handled, deferral scheduled as 6b
5c selection epic/1411-phase-5c-selection #1526 open, stacked on #1477; endpoints in copy-path raw coordinates, prepend case unowned (see 6c)
6a collapse to one representation epic/1411-phase-6a-one-representation #1543 draft, stacked on #1429 + #1477 + #1526; net -1992, motion telemetry deleted
6b identity instead of counting epic/1411-phase-6b-identity #1477 open, MERGEABLE, rebased; greptile findings closed
6c finish the migration not started none after 6a and 6b land, before close; see Phase 6c
7 close: acceptance greps and ledger not started none see Acceptance

Remaining order, and why:

  1. Land fix(tui): store the scroll bookmark in content coordinates #1429 (5b) and fix(tui): stable transcript item ids, drop the anchor occurrence ordinal #1477 (6b): both rebased and MERGEABLE, with findings closed.
    Their fork CI shows only the missing-SSH-secret jobs as failures; Format and
    Greptile Review pass, so validation is the owner's local run. fix(tui): store the scroll bookmark in content coordinates #1429 touches the
    state file 6a deletes, so review energy is spent once.
  2. Land 5c (fix(tui): keep a transcript selection on the same text across a resize #1526), which is built on fix(tui): stable transcript item ids, drop the anchor occurrence ordinal #1477 rather than on the occurrence
    ordinal, so the selection is anchored by the stable item id and needs no
    second rewrite. It becomes a single commit once fix(tui): stable transcript item ids, drop the anchor occurrence ordinal #1477 merges. See "Phase 5c"
    below.
  3. 6a, one representation. Drafted as refactor(tui): one viewport representation, delete the line-index anchors #1543. One Follow value
    (Option<ContentPos> in practice), then delete the anchor,
    estimate and global-static machinery, and consolidate the tests. This is what
    makes a stored row offset stop existing.
  4. 6b, identity instead of counting (fix(tui): stable transcript item ids, drop the anchor occurrence ordinal #1477). A stable id assigned by the
    transcript container, replacing the occurrence ordinal. Independent of 6a:
    identity lives in one place, ContentPos, and the prepend anchor carries no
    identity, so either order is safe. The only coupling is that 6a also edits
    state_ui_messages.rs, so landing 6b first costs 6a a rebase.
  5. 6c, finish the migration. The gaps 6a and 6b leave behind, plus the
    one-home cleanup. Correctness first, tidying second; do not start it until
    6a and 6b have landed, because it edits the same files.
  6. Close: the acceptance greps below, ledger updated with real counts, then close
    this issue and Chat viewport jumps to a different position when the terminal is resized #1412.

Phase 6c: finish the migration

6a and 6b end the interaction and ordinal classes, but three things they
introduced or left are not covered, and one requirement of Acceptance 2 was
never assigned to a phase. Scoped as a cleanup, correctness first:

Correctness:

  1. A selection endpoint across a prepend. Acceptance 2 names it explicitly
    ("a prepended duplicate cannot move a paused reader, a bookmark, or a
    selection endpoint") and no phase delivered it. 5c anchors endpoints in the
    copy path's raw text coordinates, which are width-independent but not
    prepend-independent: the raw line index shifts when older history is inserted
    above, and nothing re-bases an active selection. Either give the endpoint an
    item-keyed position or re-base it on a prepend, then test it.
  2. A reader parked in the padded header across a resize. content_pos_at_row
    treats a section prepared without a wrap map (the padded header, inline
    images, batch progress) as one raw line per rendered row. The header re-wraps
    at a new width, so that assumption can move the content under the reader.
    Declare re-wrappability on PreparedMessages instead of inferring it from an
    empty map, and cover the case.
  3. The compacted-history marker as the anchor across a prepend. The marker
    is regenerated on each load, so it is a new item and the anchor cannot
    resolve; the fallback keeps the previous row. That is probably the right
    behaviour at the very top, but it is neither stated nor tested.

Tidying (deletion-shaped, no user-visible change):

  1. Fold the bookmark onto ContentPos. It is Option<Anchor> today, and
    Anchor is ContentPos::Message's payload. After the fold Anchor,
    anchor_at_row and resolve stop being public API.
  2. One identity rule. Make identity mandatory on TuiState so
    display_item_id loses its ItemId(stable_cache_hash()) fallback.
  3. One home for the position concept. "Where is the reader" is currently
    spelled across six modules, with its setters in a 2,000-line input file.

Considered and not taken:

  1. Merging Transcript::replace's reconciliation with the frame's reuse
    matchers: rejected.
    replace assigns ids by matching content; the frame's
    matchers consume ids and hashes to decide whether a prepared body can be
    reused. They share stable_cache_hash by contract, not by duplication, so
    merging them would couple two computations that change for different reasons.
  2. Collapsing Transcript's parallel items/ids vectors: real, but it is a
    re-core of the container, not a cleanup step.
    It makes desync
    unrepresentable at the cost of the Deref<Target = [DisplayMessage]> that
    165 production and 530 test sites read through, against 18 production
    mutation sites. Either take it as its own bounded pass, or keep the vectors
    and add debug_assert_eq!(items.len(), ids.len()) after each mutation (~7
    lines, catches a desync in every test run) and revisit if a DerefMut
    appears or anything outside transcript.rs starts mutating both.

Phase 5c: transcript selection (stacked on 6b, #1526)

Selection endpoints are wrapped line indices, so a resize rewrap silently
reinterprets them and the selection starts covering different text: a drag over
TOKEN026..TOKEN028 at 100 columns copied a stray wrapped fragment plus
TOKEN018 after resizing to 60. Phase 5c captures both endpoints in content
coordinates in commit_resize_redraw (the stable item id + row, plus the display
column measured from the start of the message rather than the wrapped row) and
re-bases them onto the frame laid out at the new width. Only the transcript pane
is rebased; the input, side-panel, diff and pinned panes are not
transcript-relative.

It adds resolve_range to crates/jcode-tui-messages/src/anchor.rs, the
id-agnostic half of resolve, so a caller can walk an item's rows rather than
only resolving one, and the fails-first test
transcript_selection_covers_the_same_text_after_a_resize.

Built on 6b rather than rebased onto master: the endpoints are then named by
#1477's stable item id, so the occurrence ordinal never enters the selection code
and there is no later conversion. The PR is stacked on #1477 and should land
after it; until then its diff carries #1477's commits, and it rebases to a single
commit on master once #1477 merges.

Revision to phase 4's identity decision. Phase 4 chose msg_hash plus an
occurrence index over ItemId, correctly, because it was the cheapest thing that
worked for that PR. It is order-dependent, so it is a ceiling rather than a
solution, and both remaining findings live under it. 6b is where the ceiling goes.

Revision to the cost that decision was based on. Phase 4 priced the id ledger
at "about 27 non-test mutation sites". That figure counted test modules. The real
production surface is 7 mutations of App.display_messages, and the 127
DisplayMessage { .. } construction sites are irrelevant because the container
assigns the id on entry, not the constructor. So 6b is a small change rather than
a sweep, which is what makes it worth doing instead of living with the ceiling.

Why one representation instead of more guards

Measured, not asserted. Four ways of remembering the reader's position coexist
today, by non-test reference count: scroll_offset (126), HistoryScrollAnchor
(16), PendingResizeAnchor (18), and the new ContentPos. Three review findings
arrived in one day on #1427, each one two of these crossing a geometry change, and
the third was caused by the guard added to fix the second. A rule that
arbitrates between two representations is strictly more fragile than deleting one
of them, which is why 6a is a deletion phase and not a patch phase.

The enforcement is the compiler, not review: if the only storable position type is
ContentPos, and the resolver's output is a per-frame local that nothing keeps,
then "a layout coordinate survived a reflow" is not expressible.

TL;DR (plain language)

The chat viewport stores the reader's position as a wrapped line index. That
number only means something for one specific window width and one specific
transcript. Every time the layout changes for any reason other than the user
scrolling, some code has to notice and patch the number, and any source nobody
wired up produces a visible jump. This epic replaces the stored form with a
content anchor (which message, which row inside it) and derives the line index
per frame. Then resize, older-history prepend, streaming, and any future reflow
all become one code path instead of N patches.

The invariant

View state is expressed in model coordinates. Layout coordinates are recomputed
every frame and never stored.

Three layers, one-directional:

  1. Content: an append-only list of items with stable ids.
  2. Layout: a pure function layout(content, width) -> FrameGeometry, memoized by
    (content_version, width).
  3. View: Follow::Tail or Follow::Anchored(Anchor) where
    Anchor = { item_id, row_within_item }.

resolve(follow, geometry) -> row_offset is the only place that ever converts
view state into a line number.

Evidence this is a real root issue, not required accommodation

The codebase already contains five independent compensations for the same
mismatch, each with its own comment about not teleporting the reader:

  • prepend anchor: HistoryScrollAnchor (app.rs:625), capture_history_anchor
    and reconcile_history_anchor (state_ui_messages.rs:634, :661).
  • tail-follow / streaming distance-from-bottom semantics
    (navigation.rs:1788, ui_viewport.rs:81).
  • scroll_max_estimate + estimated_chat_wrapped_lines re-deriving the extent
    at the current width (navigation.rs:375, :407).
  • scroll bookmark storing a raw line index (state_ui.rs:628).
  • reasoning-trace GC carrying wrapped_lines_at_anchor (input.rs:3458).

Plus two content-anchor mechanisms that prove the pattern is feasible: the
prompt-position table (ui.rs:328, state_ui_runtime.rs:360) and per-prompt
wrapped starts in the prepared frame.

654 references to the view-state identifiers exist across crates/jcode-tui and
crates/jcode-tui-core; 196 are on non-test paths.

What is genuinely necessary

The terminal is line-based. Line offsets must exist for the scrollbar thumb
(handterm_native_scroll.rs:149), clamping to [0, max_scroll], native-scroll
PaneState, and the remote state JSON (remote.rs:1779). The point is not to
remove line offsets, it is to stop storing them.

Phases

Each phase is one PR, independently mergeable and revertable, and links this
issue. Status for each is in the table above.

  1. Unblind the motion telemetry. Make the anchor-stability recorder stop
    classifying width changes as expected motion.
    Folded into phase 4. It
    cannot in principle separate a correct reflow from a jump, because a rewrap
    changes every row hash. The recorder is a debug surface, and each phase is
    verified by a behavioral regression test instead.

  2. Snap on resize. One line in the resize path; closes the transient half of
    Chat viewport jumps to a different position when the terminal is resized #1412 on its own.

  3. Publish the frame. One immutable per-frame layout snapshot, replacing the
    scattered LAST_* statics. Shipped as the prepared frame itself; adding a
    parallel FrameGeometry type would be a second source of truth, the exact bug
    class this epic removes. Consumer migration moves to phase 6.

  4. Anchor to items. Anchor, ContentPos and a pure resolve. The real fix
    for Chat viewport jumps to a different position when the terminal is resized #1412. Identity is msg_hash plus an occurrence index, revised in 6b.

  5. Migrate the remaining consumers. Bookmark, text selection, prompt-jump.

  6. Collapse and delete, in two steps.

    Why both: 6a stops the interaction class, 6b stops the ordinal class. Either
    alone leaves the other live. Together they are the point at which this code
    stops producing findings.

Ordering constraint: seam before semantics. Phase 4 needs phase 3 published, and
phase 6 needs phases 4 and 5 landed, because the anchor, bookmark, and selection
must all be anchor-based before the enum can replace the flags. Within phase 6,
6a and 6b are independent in correctness terms and may land in either order.
Do not big-bang; each phase is independently revertable.

Deletion ledger

Counts are from wc and grep on c9062899b. Biggest deletions: the history
anchor and its reconcile path plus the scroll-handler anchor branches (~230),
global statics plumbing in ui.rs (13 statics, 19 test twins, 20 accessors, ~180),
overscroll and gesture mode bookkeeping that only disambiguates line-space modes
(~80), scroll_max_estimate and its cache (~75), and tail-follow catch-up statics
(~85). Added back: the published frame (FrameGeometry
was dropped in phase 3), ContentPos, Anchor, resolve, and in 6b a per-item
id with its container wrapper, about 400 to 700 lines. Phase 6b was re-costed on
2026-09-23 from "about 27 non-test mutation sites" to 7, see the revision
above.

Expected net: production drops by roughly 600 lines now, 1.5k if the motion
telemetry is dropped, and roughly 4 to 5k including tests. Line count goes up
during phases 3 and 4 before it comes down.

Actuals to date: 6a (#1543) is +520 / -2512, net -1992 (production -1084,
tests -908); the epic as a whole, counting the open stack, is still net positive
because 6b and phase 4 added more than 6a and the telemetry deletion remove.
Phase 7 should record the final per-phase counts here.

Resolved: the motion telemetry is deleted in phase 6a (#1543). It cannot
separate a correct reflow from a jump, its input was the two fields 6a removes
(scroll_offset, following_tail), and every phase since has been verified by
a behavioural regression test instead. The smoothness / smoothness:reset
debug commands go with it.

Non-goals

  • Removing the line-based rendering layer. It stays.
  • Changing scroll feel, overscroll, catch-up animation, or prompt-jump UX.
  • A single-PR rewrite.
  • Accepting the occurrence-ordinal ceiling as permanent. It is scheduled work now.
  • Any new guard or rule between two representations of the position.

Acceptance

The behavioral suite (scroll_copy_01/02/03, tests_input_scroll,
smoothness_benchmark) stays green throughout. New coverage is pure-function
tests over resolve, asserting that for any content, widths w1 and w2, and
anchor a, the item and row under the anchor's top row are preserved across the
reflow.

Two structural criteria, greppable rather than a matter of judgement:

  1. One representation (6a). No stored row offset. scroll_offset,
    auto_scroll_paused, pending_history_anchor, lines_from_bottom,
    scroll_max_estimate, last_total_wrapped_lines and
    last_resolved_chat_scroll survive only as frame-derived reads on the render
    path, with zero stored-form uses, and the resolver's output is not a field
    anywhere.
  2. Identity, not counting (6b). A position names an item by a stable id. No
    occurrence ordinal remains, and a prepended duplicate cannot move a paused
    reader, a bookmark, or a selection endpoint.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: tuiTerminal user interface, rendering, and interactions.autonomous: noNeeds your brain: a product/design decision is required before anyone acts.enhancementNew feature or requestsize: XLArchitectural change, major migration, or redesign spanning much of the system.type: refactorRestructures code without intended behavior changes.

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions