Skip to content

fix(triage_client): correct scrollback row indices after a clear or a resize - #137

Merged
iamstuffed merged 9 commits into
fix/mobile-copyfrom
fix/xterm-scrollback-clear
Aug 11, 2026
Merged

fix(triage_client): correct scrollback row indices after a clear or a resize#137
iamstuffed merged 9 commits into
fix/mobile-copyfrom
fix/xterm-scrollback-clear

Conversation

@iamstuffed

@iamstuffed iamstuffed commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Stacked on #136. Based on fix/mobile-copy so the diff stays clean.

Two defects in xterm.dart's IndexAwareCircularBuffer, both corrupting BufferLine.index, which is what every scrollback consumer resolves a row through. TerminalScrollAnchor pins the viewport at line.index * lineHeight, so a scrolled-up viewport jumps; selection anchors resolve rows through it, so a held selection renders and copies from the wrong lines.

1. trimStart leaves a mixed index space

trimStart advances the list's start without advancing the absolute start that every element measures its index from. Its only caller is Buffer.clearScrollback, which runs on ESC[3J, so after anything runs clear surviving lines report a row too high by the number trimmed while lines written afterwards report correctly.

2. replaceWith rotates the buffer against itself

A width change reflows through replaceWith, which adopts the new lines through _getCyclicIndex (offset by _startIndex) and only resets _startIndex afterwards. It therefore writes at one rotation and reads back at another.

This one needs no clear at all. push rotates the array every time it evicts from a full scrollback, so writing past maxLines and then resizing the window is enough. Measured against stock 4.0.0:

  • With a replacement that fills the list, every row answers with another row's line. Terminal(maxLines: 30), 60 lines written, then a width change: row 0 reports 29 and row 11 reports 10.
  • With a shorter replacement (widening a window whose lines were wrapped), the reflow also leaves rows it never wrote. A row can then answer with the wrong new line, hand back a line trimStart dropped, or throw Null check operator used on a null value from operator [], and which row does which is not worth predicting. Rotate a list of 4 by 2 and replace with 3 and it reads back as the third replacement element, then a throw, then the first.

That second case is a crash in the shipped release, reachable with nothing but a full scrollback and a resize. It is a better candidate for the originally reported duplicated content and missing history than the clear path was.

It also mattered to defect 1's fix: this branch identifies a cleared line by its negative row, and before this the guards misfired on the resize path, treating live rows as dead.

Why a fork

Defect 1 exists upstream only as PR #225, unmerged since 2026-05-08 against a release that is two years old. A pull request is not something a build can depend on, so dependency_overrides points at hyeons-lab/xterm.dart, pinned by commit rather than branch so a force-push cannot swap the emulator with no diff here. Defect 2 is not in #225; it needs its own upstream issue, which is noted as a follow-up.

The retire-stale-selections work could have been done locally. The deciding factor was the scroll anchor: that arithmetic lives inside xterm's data model, where no client code can reach it.

Branched from the v4.0.0 tag rather than master, which carries five commits beyond the tag (four non-merge), two of them behavioural: colour rendering and Android enter-key handling. Against the release, lib/ differs by exactly one file.

Why the narrow fix

The intuitive patch for defect 1 also detaches the trimmed lines. That version was already tried and walked back, and I wrote it before finding #225: CellAnchor.y and .offset guard _owner!.index with assert(attached) alone, so in release builds, where the assert is stripped, an anchor still holding one dereferences a null index and throws. It passes every debug test and crashes on device.

The same constraint shapes defect 2's fix. Resetting the start before adopting means _adoptChild would detach whatever occupies each slot, and after a trim those are exactly the lines that must stay attached. So the leftover slots are cleared without detaching, and the contract holds through a reflow.

Leaving them attached makes retiring stale holders our job, which is the rest of this change. attached cannot be the signal, since not detaching is the point; a cleared line instead sits before the start of the buffer and reports a negative row.

Verification

Eleven tests drive a real ESC[3J and real resizes through the emulator rather than simulating a trim. All six covering clear-then-width-change fail against the previous pin (5 pass, 6 fail of 11), including terminalSelectionIsLive reporting a row that is genuinely present as dead.

Run against stock 4.0.0's circular_buffer.dart, nine of the fork's tests fail, so every test the fork adds is load-bearing. 121 tests there, 307 here, flutter analyze clean.

Not covered, and worth stating: both terminalSelectionIsLive call sites in the pane can be removed with the suite green. No widget test creates a selection, and the pane renders the FLUTTER_TEST fallback rather than xterm's TerminalView. The emulator-level behaviour they rest on is covered; the wiring is not.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

`CircularList.trimStart` advances the list's start without advancing the
absolute start that every element measures its index from. Its only
caller is `Buffer.clearScrollback`, which runs on `ESC[3J`, so after
anything runs `clear` the buffer holds a mixed index space: surviving
lines report a row too high by the number trimmed, while lines written
afterwards report correctly. A scrolled-up viewport therefore jumps, and
a held selection renders and copies from the wrong lines.

Upstream has this only as PR 225, unmerged since 2026-05-08 against a
release that is two years old, so `dependency_overrides` now points at a
fork carrying it. The retire-stale-selections half could have been done
locally, but the drift also drives TerminalScrollAnchor, which pins the
viewport at `line.index * lineHeight`; that is arithmetic inside xterm's
own data model, where no amount of client code can reach it.

The fork takes PR 225's narrowed approach and deliberately does not
detach the trimmed lines. Detaching looks more complete and was already
tried and walked back: `CellAnchor.y` and `.offset` guard `_owner!.index`
with `assert(attached)` alone, so in release builds, where the assert is
stripped, an anchor still holding one dereferences a null index and
throws. That version passes every debug test and crashes on device.

Leaving them attached makes retiring stale holders our job, which is the
rest of this change. `attached` cannot be the signal, since not detaching
is the point; instead a cleared line now sits before the start of the
buffer and so reports a negative row. The scroll anchor releases on that,
and the pane both stops offering a copy and clears the selection, which
takes the stale highlight with it.

Tested by driving a real `ESC[3J` through the emulator. The override was
confirmed to be load-bearing by re-running those tests without it: a
surviving row reads 16 rather than 0, and the anchor stays pinned at 30.0
instead of releasing.
Copilot AI lite review requested due to automatic review settings August 10, 2026 03:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a row-indexing inconsistency triggered by ESC[3J (scrollback clear) in the Flutter client’s terminal stack. It pins xterm to a fork containing an upstream-but-unreleased fix so BufferLine.index stays consistent after a clear, then updates local scroll anchoring and selection/copy handling to correctly treat cleared-out lines as stale (identified via negative indices) rather than relying on attached.

Changes:

  • Add a dependency_overrides pin to a forked xterm.dart that advances the absolute start index during trimStart, keeping BufferLine.index correct after scrollback clear.
  • Retire/ignore stale selections after scrollback clear via a new terminalSelectionIsLive helper and updated copy-button/selection logic in the terminal pane.
  • Add unit tests that drive a real ESC[3J to verify surviving indices, scroll-anchor behavior, and stale-selection detection.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated no comments.

Show a summary per file
File Description
flutter/triage_client/test/terminal/scrollback_clear_test.dart New tests covering ESC[3J effects on line indices, scroll anchoring, and selection liveness.
flutter/triage_client/pubspec.yaml Pins xterm to a git fork via dependency_overrides, with detailed rationale and removal note.
flutter/triage_client/pubspec.lock Locks xterm to a specific git commit (resolved-ref) for reproducible builds.
flutter/triage_client/lib/widgets/terminal_pane_stub.dart Clears/filters selections that refer to cleared rows so the copy UI and highlight don’t desync.
flutter/triage_client/lib/terminal/terminal_selection.dart Adds terminalSelectionIsLive helper used to detect selections spanning cleared (negative-index) rows.
flutter/triage_client/lib/terminal/terminal_scroll_anchor.dart Drops the scroll anchor when the anchored line’s index goes negative after scrollback clear.
devlog/plans/000118-01-xterm-scrollback-clear.md Plan write-up describing the issue, fork rationale, and implementation steps.
devlog/000118-fix-xterm-scrollback-clear.md Branch devlog recording decisions, verification, and the dependency pin.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

The override branched from upstream master, which carries three
unreleased commits beyond v4.0.0, two of them behavioural: colour 15
rendering in palette_builder.dart, and Android enter-key handling in
terminal_view.dart. The pubspec described it as one fix and pubspec.lock
still reports 4.0.0, so nothing signalled that colour and keyboard
behaviour were changing alongside a scrollback fix.

Rebranched from the v4.0.0 tag, so lib/ differs from the release by
exactly one file, and repinned by commit rather than branch name: a
branch ref lets a force-push swap the terminal emulator this app ships
with no diff here.

Also records what the fix does not cover. A width-changing resize after
a clear resurrects the cleared lines, because replaceWith computes
cyclic indices from a _startIndex that trimStart leaves non-zero, and
every live row then reports a negative index. Both guards added here
misfire in that path, conservatively. The resurrection is pre-existing
and present in stock 4.0.0.
The pubspec said the pin was the release plus one commit; it is two, the
fix and a dev-dependency removal. That second one is genuinely
consumer-invisible, since pub does not resolve dev_dependencies of a
non-root package, but a supply-chain reviewer reading the comment would
expect the diff to show one commit and it does not. Says lib/ carries
one change instead, which is the claim that matters and is verifiable.

Also points the superseded pinning decision at the entry that replaced
it, so a reader looking for the rationale does not stop at a commit that
is no longer in the build.
A width change reflows through `replaceWith`, which adopted the new
lines against a rotated start and only reset that start afterwards. The
buffer came back rotated against itself: every row answered with another
row's line, and where the reflow was shorter than the rotation the
leading slots were never written, so cleared lines reappeared there or
reading row 0 threw.

Nothing about this needs a `clear`. `push` rotates the array every time
it evicts from a full scrollback, so writing past `maxLines` and then
resizing the window is enough, which makes it a better candidate for the
duplicated content and missing history than the clear path was.

Repins the xterm override onto a fork commit carrying that fix and adds
six cases covering clear-then-width-change, five of which fail against
the previous pin. The guards this branch added for cleared rows were
misfiring on that path, treating live rows as dead, so the rule they
rest on now holds on resize as well.
@iamstuffed iamstuffed changed the title fix(triage_client): keep row indices correct across a scrollback clear fix(triage_client): correct scrollback row indices after a clear or a resize Aug 10, 2026
Repins onto the fork commit that corrects the second fix's comments.
The previous wording claimed a short replacement leaves the leading
slots unwritten; measured on the unfixed code, a list of 4 rotated by 2
with a replacement of 3 reads back as the third element, then a null
dereference, then the first. Slot 0 is written and row 1 throws, so a
row can hand back the wrong new line, a cleared one, or throw.

Three counts in the pubspec and devlog were also wrong: master carries
five commits beyond the tag rather than three, the fork's tests fail 9
against stock rather than 7, and the devlog recorded a pin two commits
behind the one shipped. The pubspec no longer states a commit count at
all, having had it wrong three times; it says what `lib/` contains and
points at `git diff v4.0.0..<ref> -- lib/`.

Strengthens the scroll-anchor case, which still passed against the old
pin: the offset reads 20 either way because the rotation gives that line
the same absolute index, so it now also asserts which line sits at the
row. All six width-change cases now fail against the previous pin.
The enumeration replacing the commit count was itself incomplete: two of
the branch's commits are comment corrections, and one of those edits
circular_buffer.dart rather than only tests.
The override comment enumerated two fixes in `lib/`, but the second
carries a third behavioural edit it only gestured at: `replaceWith`
clears the slots it reuses without detaching what they held, because
after a trim those are lines the first fix deliberately left attached.
Detaching them is the release-build crash that fix exists to avoid, so
it is load-bearing rather than incidental.

Also records in the devlog that both `terminalSelectionIsLive` call
sites in the pane are uncovered: they are mobile-only, so the widget
harness never reaches them, and the Verification section previously read
as though the whole change was tested.
The comment correction this branch claimed at 02:32 never reached
`lib/`. A control run that verified the claim ended by checking the file
out from HEAD in the same shell, reverting the uncommitted edit, so the
fork commit carried only its tests while its message and this repo's
pubspec both said the wording had been fixed.

Also corrects the devlog's reason for the pane's `terminalSelectionIsLive`
call sites being untested. It said they are mobile-only, but the pane's
`_isMobile` has no `runningUnderFlutterTest` carve-out and the harness
reports android. They are unreached because no widget test creates a
selection and the pane renders the FLUTTER_TEST fallback view.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 8 changed files in this pull request and generated no new comments.

@iamstuffed
iamstuffed merged commit 7ef196f into fix/mobile-copy Aug 11, 2026
8 checks passed
@iamstuffed
iamstuffed deleted the fix/xterm-scrollback-clear branch August 11, 2026 02:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants