Skip to content

fix: Keep the table highlight on screen when the cursor is placed, not moved - #981

Merged
huang195 merged 3 commits into
rossoctl:mainfrom
huang195:fix/tui-cursor-visibility
Sep 14, 2026
Merged

huang195 merged 3 commits into
rossoctl:mainfrom
huang195:fix/tui-cursor-visibility

Conversation

@huang195

Copy link
Copy Markdown
Member

Problem

In the events pane, scrolling to the bottom and pressing ↑ scrolls the rows one at a time
with no row highlighted anywhere.

Root cause

table.SetCursor moves the cursor without reconciling the viewport's scroll offset.

UpdateViewport renders a window of rows around the cursor —
start = clamp(cursor−height, 0, cursor) — and the viewport then shows height lines of that
window beginning at its own YOffset, which only MoveUp/MoveDown maintain. So with
YOffset 0 and a cursor at or past one screenful, start lands on cursor−height and the
cursor sits at window line height — exactly one line past the last visible one. The row is
rendered and the highlight is drawn on it, off the bottom edge.

MoveUp cannot recover from that state either: its cases are start == 0, start < height,
and YOffset >= 1, and none match, so YOffset stays 0 while start walks up with the
cursor. Hence the symptom — rows scroll under the arrow key, nothing is ever highlighted.

Measured on a 40-row session at height 11, driving the real table through the real key path:

how the bottom was reached cursor highlighted row on screen? rows visible
holding ↓ (MoveDown) 39 yes 29..39
G / end (SetCursor(n-1)) 39 no 28..38
↓ to the bottom, then one rebuild 39 no 28..38
after G, then ↑ ×6 38→33 no, never returns 27..37 → 22..32

Same targets, two APIs — the boundary is exactly the table height:

SetCursor(10) ✓ on screen        GotoTop+MoveDown(10) ✓
SetCursor(11) ✗ off screen       GotoTop+MoveDown(11) ✓
SetCursor(39) ✗ off screen       GotoTop+MoveDown(39) ✓

Solution

setCursorVisible (new tui/table_cursor.go) expresses the jump as relative movement, the
only cursor API in bubbles that maintains the offset: GotoTop to normalize it, then one
MoveDown of n — a distance, not a loop, so two O(height) re-renders however far the cursor
travels. Its doc comment carries the mechanics above so the next person does not re-derive
them.

Every programmatic cursor placement now goes through it:

  • rebuildEventsTable auto-follow and position-restore. This is why the bug appeared even
    when the bottom had been reached with the arrow keys: the pane rebuilds on every incoming
    event and the restore lost the highlight again. The restore is now unconditional — the
    old else if prevRow < len(rows) skipped it when the rows shrank under the cursor (a
    filter typed, hideInactive toggled), leaving the cursor wherever SetRows had clamped it
    with an unreconciled offset.
  • goTop / goBottom (g and G) for all four tables; the empty-row guards fold into
    the helper, which clamps.
  • sessions pane restore-by-session-id, and the pipeline pane divider-skip nudges,
    which become MoveUp/MoveDown of 1.

PgUp/PgDn were already relative moves and were never affected.

Testing

tui/table_cursor_test.go asserts what an operator sees, not the cursor index — the index
was always correct, it was the rendered window that excluded it. Each fixture row gets a
unique host, and the selected row's text must appear in the table's View():

  • TestSetCursorVisible_LandsOnScreen — targets on both sides of the height boundary
  • TestSetCursorVisible_ClampsAndSurvivesEmpty — out-of-range targets and an empty table
  • TestEventsTable_AutoFollowKeepsSelectionVisible — the reported sequence: rebuild, ↑,
    rebuild, ×6
  • TestEventsTable_RebuildMidListKeepsSelectionVisible — a new event while parked mid-list
    must move neither the cursor nor the highlight off screen
  • TestGoBottom_KeepsSelectionVisibleG, then ↑ ×3, then g
  • TestEventsTable_ResizeKeepsSelectionVisibleSetHeight re-windows rows the same way

Against a stub that kept the old behaviour these fail 31 times, with the boundary exactly
at the table height.

go test ./... in cmd/abctl passes except TestRunExec_BeforeFirstStartRunsAndSaysWhatIsLost
("the child inherited a bundle path that does not exist"), which fails identically on an
unmodified upstream/main — pre-existing and unrelated. go vet clean; golangci-lint --new-from-rev=upstream/main 0 issues; gofmt clean.

Notes

  • Independent of fix: Make every COST cell answer for its own row, and publish the output half #980 (row-local COST cells); both branch from main and touch different
    regions of events_pane.go, so they merge in either order.
  • Upstream shortcoming rather than misuse: SetCursor is a reasonable-looking API with no hint
    that it leaves the offset stale. Worth an issue against charmbracelet/bubbles — the helper
    is the local guard either way.

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

…t moved

Scrolling to the bottom of the events pane and pressing up scrolled the rows one
at a time with no row highlighted anywhere.

table.SetCursor moves the cursor without reconciling the viewport's scroll
offset. UpdateViewport renders a WINDOW of rows around the cursor —
start = clamp(cursor-height, 0, cursor) — and the viewport shows `height` lines of
that window beginning at its own YOffset, which only MoveUp/MoveDown maintain. So
with YOffset 0 and a cursor at or past one screenful, start lands on
cursor-height and the cursor sits at window line `height`: exactly one line past
the last visible one. The row is rendered and the highlight is drawn on it, off
the bottom edge.

MoveUp cannot recover from that state — its cases are start == 0, start < height,
and YOffset >= 1, and none match — so YOffset stays 0 while start walks up with
the cursor. That is the reported symptom: the rows scroll under the arrow key and
no row is ever highlighted.

Measured on a 40-row session at height 11: SetCursor(10) leaves the cursor on
screen, SetCursor(11) and every target above it do not.

setCursorVisible expresses the jump as relative movement instead — GotoTop to
normalize the offset, then one MoveDown of n, which is a distance and not a loop,
so it costs two O(height) re-renders however far the cursor travels. Every
programmatic cursor placement now goes through it:

- rebuildEventsTable's auto-follow and position-restore. This is why the bug
  showed up even when the bottom had been reached with the arrow keys: the pane
  rebuilds on every incoming event, and the restore lost the highlight again.
  The restore is also unconditional now — the old `else if prevRow < len(rows)`
  skipped it entirely when the rows shrank under the cursor (a filter typed,
  hideInactive toggled), leaving the cursor whereever SetRows had clamped it with
  an offset nobody reconciled.
- goTop / goBottom, i.e. the g and G keys, for all four tables. The empty-row
  guards fold into the helper, which clamps.
- the sessions pane's restore-by-session-id, and the pipeline pane's
  divider-skip nudges, which become MoveUp/MoveDown of 1.

PgUp/PgDn were already relative moves and were never affected.

Tests assert what an operator sees rather than the cursor index — the index was
always correct, it was the rendered window that excluded it — by giving each
fixture row a unique host and requiring the selected row's text to appear in the
table's View(). Against the stub that kept the old behaviour they fail 31 times,
with the boundary exactly at the table height.

Verified: go test ./... in cmd/abctl passes except
TestRunExec_BeforeFirstStartRunsAndSaysWhatIsLost, which fails identically on an
unmodified upstream/main and is unrelated; go vet clean; golangci-lint
--new-from-rev=upstream/main reports 0 issues; gofmt clean.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@huang195
huang195 requested a review from a team as a code owner September 13, 2026 19:04
@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 14 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 45da7d3b-89a6-49e8-85ed-f4f351f880dd

📥 Commits

Reviewing files that changed from the base of the PR and between 2492c21 and 2e8f703.

📒 Files selected for processing (6)
  • authbridge/cmd/abctl/tui/events_pane.go
  • authbridge/cmd/abctl/tui/keys.go
  • authbridge/cmd/abctl/tui/pipeline_pane.go
  • authbridge/cmd/abctl/tui/sessions_pane.go
  • authbridge/cmd/abctl/tui/table_cursor.go
  • authbridge/cmd/abctl/tui/table_cursor_test.go

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

… case costs

Review catch: three of the converted call sites had no test, and the rows-shrink
case was mis-described.

The description said the unconditional restore fixes a cursor "left wherever
SetRows had clamped it, with an offset nobody reconciled". Measured, it is worse
than that. rebuildEventsTable clears the table first (SetRows(nil)), which clamps
the cursor to len(rows)-1 = -1, and the refill does not restore it because -1 is
not greater than the new len-1. The old `else if prevRow < len(rows)` guard was
false whenever the rows shrank past the cursor, so nothing restored it either and
the cursor stayed at -1: NO ROW SELECTED AT ALL. Not a highlight one line off —
SelectedRow returns nil, selectedEventRow reports not-ok, and the detail pane goes
blank. Typing a filter or toggling hideInactive while parked below the new row
count did that every time, on any list length. Pinned red:

  parked at row 30: selected row 30 is off screen; the view shows rows 19..29
  after the filter shrank the list: no row selected (cursor=-1)
  no selected row resolves after the shrink

New tests, each verified red against the pre-fix files:

- FilterShrink, over both a moderate shrink (ten rows) and a drastic one (one row,
  shorter than the table), plus the round trip back. A drastic shrink is
  self-correcting for the OFFSET — the viewport clamps when the content gets
  shorter than YOffset — so only the moderate case exercises that half, while both
  exercise the lost selection.
- HideInactiveShrink, which removes rows from the MIDDLE, so no surviving row keeps
  its old index.
- SessionsTable_RestoreByID: the sessions pane restores by session id on a poll
  rather than a keystroke, so the same lost highlight was one refresh away there
  too. Covers the fallback-to-row-0 branch that absorbed the old len(rows) > 0
  guard, and an empty list after it.

The divider-nudge tests PIN behaviour rather than catch a regression: MoveDown(1)
and SetCursor(+1) agree on the index, including the clamp at both ends, so they
pass either way. They exist because the offset does move now — skipping the divider
can scroll the pane by a line — and because both ends and both directions should
stay pinned while that is true.

Two nits, also from review:

- the empty-table assertion accepted `> 0`, which admits 0 — itself not a row on an
  empty table. It now pins "the cursor is left exactly as it was", which is all the
  helper promises, over both ways a table can be empty: fresh (cursor 0) and
  emptied by SetRows(nil) (cursor -1). This is what caught the stub moving a fresh
  empty table's cursor from 0 to -1.
- stray "in it" in table_cursor.go's MoveUp paragraph.

assertSelectionVisible now reads the marker off the SELECTED ROW instead of deriving
it from the cursor index, because under a filter row N is no longer event N and the
old form would have asserted that some unrelated row was on screen.

Verified: go test ./tui/ passes; gofmt clean; go vet clean; golangci-lint
--new-from-rev=upstream/main 0 issues.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Reported from a running session: scrolling to the very last message highlights it,
then a second later "it deselects that last row, goes one line up, and the highlight
disappears" — consistently, on a ~1s cadence.

Same root cause as this branch already fixes, and the measurement says so precisely:
the cursor never moves. Against the pre-fix files, with an event arriving each tick,

  tick 1: selected row 40 is off screen; the view shows rows 29..39
  tick 2: selected row 41 is off screen; the view shows rows 30..40

Auto-follow puts the cursor on the new last row every time and the index assertion
never fires — what is wrong is that the rendered window ends one row short of it. The
selection is on row 40 while the screen shows up to 39, which is indistinguishable
from a deselection that scrolled up a line. The one-second cadence is the poll's
rebuild, which is when SetCursor re-windows the rows.

Kept as its own test because the existing auto-follow test rebuilds WITHOUT adding
events, so it never exercised the tail growing under the cursor — the shape every
live session is in.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>

@mrsabath mrsabath left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

一针见血 (yī zhēn jiàn xiě — "one needle draws blood," i.e. straight to the heart of it). Excellent, self-contained fix, Hai.

I verified the root-cause analysis against charmbracelet/bubbles v1.0.0 (pinned in cmd/abctl/go.mod): SetCursor clamps and calls UpdateViewport but never reconciles viewport.YOffset, while GotoTop/MoveUp/MoveDown do — so setCursorVisible's GotoTop() + MoveDown(n) is exactly the relative-move mechanism the library itself uses for jumps (GotoBottom = MoveDown(len(rows))). The clamp matches SetCursor's own, so it's behavior-preserving on the index while fixing the offset.

The divider-nudge conversion from SetCursor(±1) to MoveUp/MoveDown(1) is index-equivalent including the end clamps (pinned by TestPipelineTable_DividerNudgeClampsAtTheEnds). Making the rebuildEventsTable restore unconditional correctly closes the rows-shrink case the old else if prevRow < len(rows) skipped.

Tests are a highlight: they assert on the rendered View() (what the operator sees) rather than the cursor index (which was always correct), and cover both sides of the height boundary, both shrink levers, resize, live-tail, and empty-table edge cases.

Areas reviewed: Go (TUI / bubbles table), tests, commit/PR conventions, security.
Commits: 3, all signed-off (DCO ✓).
CI status: all green.

@huang195
huang195 merged commit 0c9f091 into rossoctl:main Sep 14, 2026
25 checks passed
@huang195
huang195 deleted the fix/tui-cursor-visibility branch September 14, 2026 13:24
@github-project-automation github-project-automation Bot moved this from New/ToDo to Done in Rossoctl Issue Prioritization Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants