You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
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.
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:
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.
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.
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):
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.
One identity rule. Make identity mandatory on TuiState so display_item_id loses its ItemId(stable_cache_hash()) fallback.
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:
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.
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:
Content: an append-only list of items with stable ids.
Layout: a pure function layout(content, width) -> FrameGeometry, memoized by (content_version, width).
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).
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.
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.
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.
Migrate the remaining consumers. Bookmark, text selection, prompt-jump.
Collapse and delete, in two steps.
6a, one representation. One Follow value (Tail or Anchored(ContentPos)), then delete the anchor, estimate, and global-static
machinery, and consolidate the tests. About 600 production lines out.
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:
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.
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.
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.rsVersion: 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)
epic/1411-phase-2epic/1411-phase-3ContentPosepic/1411-phase-4epic/1411-phase-5a-prompt-jumpepic/1411-phase-5b-bookmarkepic/1411-phase-5c-selectionepic/1411-phase-6a-one-representationepic/1411-phase-6b-identityRemaining order, and why:
Their fork CI shows only the missing-SSH-secret jobs as failures;
FormatandGreptile Reviewpass, so validation is the owner's local run. fix(tui): store the scroll bookmark in content coordinates #1429 touches thestate file 6a deletes, so review energy is spent once.
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.
Followvalue(
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.
transcript container, replacing the occurrence ordinal. Independent of 6a:
identity lives in one place,
ContentPos, and the prepend anchor carries noidentity, 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.one-home cleanup. Correctness first, tidying second; do not start it until
6a and 6b have landed, because it edits the same files.
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:
("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.
content_pos_at_rowtreats 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
PreparedMessagesinstead of inferring it from anempty map, and cover the case.
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):
ContentPos. It isOption<Anchor>today, andAnchorisContentPos::Message's payload. After the foldAnchor,anchor_at_rowandresolvestop being public API.TuiStatesodisplay_item_idloses itsItemId(stable_cache_hash())fallback.spelled across six modules, with its setters in a 2,000-line input file.
Considered and not taken:
Transcript::replace's reconciliation with the frame's reusematchers: rejected.
replaceassigns ids by matching content; the frame'smatchers consume ids and hashes to decide whether a prepared body can be
reused. They share
stable_cache_hashby contract, not by duplication, somerging them would couple two computations that change for different reasons.
Transcript's parallelitems/idsvectors: real, but it is are-core of the container, not a cleanup step. It makes desync
unrepresentable at the cost of the
Deref<Target = [DisplayMessage]>that165 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 (~7lines, catches a desync in every test run) and revisit if a
DerefMutappears or anything outside
transcript.rsstarts 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..TOKEN028at 100 columns copied a stray wrapped fragment plusTOKEN018after resizing to 60. Phase 5c captures both endpoints in contentcoordinates in
commit_resize_redraw(the stable item id + row, plus the displaycolumn 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_rangetocrates/jcode-tui-messages/src/anchor.rs, theid-agnostic half of
resolve, so a caller can walk an item's rows rather thanonly 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_hashplus anoccurrence index over
ItemId, correctly, because it was the cheapest thing thatworked 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 127DisplayMessage { .. }construction sites are irrelevant because the containerassigns 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 newContentPos. Three review findingsarrived 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:
layout(content, width) -> FrameGeometry, memoized by(content_version, width).Follow::TailorFollow::Anchored(Anchor)whereAnchor = { item_id, row_within_item }.resolve(follow, geometry) -> row_offsetis the only place that ever convertsview 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:
HistoryScrollAnchor(app.rs:625),capture_history_anchorand
reconcile_history_anchor(state_ui_messages.rs:634,:661).(
navigation.rs:1788,ui_viewport.rs:81).scroll_max_estimate+estimated_chat_wrapped_linesre-deriving the extentat the current width (
navigation.rs:375,:407).state_ui.rs:628).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-promptwrapped starts in the prepared frame.
654 references to the view-state identifiers exist across
crates/jcode-tuiandcrates/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-scrollPaneState, and the remotestateJSON (remote.rs:1779). The point is not toremove 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.
Unblind the motion telemetry.
Make the anchor-stability recorder stopFolded into phase 4. Itclassifying width changes as expected motion.
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.
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.
Publish the frame. One immutable per-frame layout snapshot, replacing the
scattered
LAST_*statics. Shipped as the prepared frame itself; adding aparallel
FrameGeometrytype would be a second source of truth, the exact bugclass this epic removes. Consumer migration moves to phase 6.
Anchor to items.
Anchor,ContentPosand a pureresolve. The real fixfor Chat viewport jumps to a different position when the terminal is resized #1412. Identity is
msg_hashplus an occurrence index, revised in 6b.Migrate the remaining consumers. Bookmark, text selection, prompt-jump.
Collapse and delete, in two steps.
Followvalue (TailorAnchored(ContentPos)), then delete the anchor, estimate, and global-staticmachinery, and consolidate the tests. About 600 production lines out.
container, and delete the occurrence ordinal. Closes the last resize corner
on fix(tui): anchor viewport state to content coordinates across a resize #1427 and the fix(tui): store the scroll bookmark in content coordinates #1429 bookmark ceiling.
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
wcandgreponc9062899b. Biggest deletions: the historyanchor 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_estimateand its cache (~75), and tail-follow catch-up statics(~85). Added back: the published frame (
FrameGeometrywas dropped in phase 3),
ContentPos,Anchor,resolve, and in 6b a per-itemid 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 bya behavioural regression test instead. The
smoothness/smoothness:resetdebug commands go with it.
Non-goals
Acceptance
The behavioral suite (
scroll_copy_01/02/03,tests_input_scroll,smoothness_benchmark) stays green throughout. New coverage is pure-functiontests over
resolve, asserting that for any content, widthsw1andw2, andanchor
a, the item and row under the anchor's top row are preserved across thereflow.
Two structural criteria, greppable rather than a matter of judgement:
scroll_offset,auto_scroll_paused,pending_history_anchor,lines_from_bottom,scroll_max_estimate,last_total_wrapped_linesandlast_resolved_chat_scrollsurvive only as frame-derived reads on the renderpath, with zero stored-form uses, and the resolver's output is not a field
anywhere.
occurrence ordinal remains, and a prepended duplicate cannot move a paused
reader, a bookmark, or a selection endpoint.