From ce2cc7302e896b1f4657c91ff6b13783cce594f1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Apr 2026 22:10:40 -0500 Subject: [PATCH] fix(tui): stabilize sticky prompt tracking Keep the latest prompt sticky while the viewport is in live assistant output beyond history, and clear stale sticky state at the real bottom using fresh scroll height. --- ui-tui/src/__tests__/viewport.test.ts | 27 ++++++++++++++++++++++ ui-tui/src/__tests__/viewportStore.test.ts | 16 +++++++++++++ ui-tui/src/domain/viewport.ts | 14 +++++++---- ui-tui/src/lib/viewportStore.ts | 11 +++++++-- ui-tui/src/types/hermes-ink.d.ts | 1 + 5 files changed, 62 insertions(+), 7 deletions(-) diff --git a/ui-tui/src/__tests__/viewport.test.ts b/ui-tui/src/__tests__/viewport.test.ts index d8500c8d2..eca079470 100644 --- a/ui-tui/src/__tests__/viewport.test.ts +++ b/ui-tui/src/__tests__/viewport.test.ts @@ -28,4 +28,31 @@ describe('stickyPromptFromViewport', () => { expect(stickyPromptFromViewport(messages, offsets, 16, 20, false)).toBe('current prompt') }) + + it('shows the last prompt once the viewport starts after the history tail', () => { + const messages = [ + { role: 'user' as const, text: 'current prompt' }, + { role: 'assistant' as const, text: 'completed answer' } + ] + + expect(stickyPromptFromViewport(messages, [0, 2, 5], 8, 14, false)).toBe('current prompt') + }) + + it('shows a prompt as soon as its full row is above the viewport', () => { + const messages = [ + { role: 'user' as const, text: 'current prompt' }, + { role: 'assistant' as const, text: 'current answer' } + ] + + expect(stickyPromptFromViewport(messages, [0, 2, 10], 2, 8, false)).toBe('current prompt') + }) + + it('hides the sticky prompt at the bottom', () => { + const messages = [ + { role: 'user' as const, text: 'current prompt' }, + { role: 'assistant' as const, text: 'current answer' } + ] + + expect(stickyPromptFromViewport(messages, [0, 2, 10], 8, 10, true)).toBe('') + }) }) diff --git a/ui-tui/src/__tests__/viewportStore.test.ts b/ui-tui/src/__tests__/viewportStore.test.ts index 16031c967..7889b65cd 100644 --- a/ui-tui/src/__tests__/viewportStore.test.ts +++ b/ui-tui/src/__tests__/viewportStore.test.ts @@ -35,4 +35,20 @@ describe('viewportStore', () => { }) expect(viewportSnapshotKey(snap)).toBe('0:16:5:40:3') }) + + it('uses fresh scroll height to clear stale non-bottom state', () => { + const handle = { + getFreshScrollHeight: () => 20, + getPendingDelta: () => 0, + getScrollHeight: () => 40, + getScrollTop: () => 15, + getViewportHeight: () => 5, + isSticky: () => false + } + + const snap = getViewportSnapshot(handle as any) + + expect(snap.atBottom).toBe(true) + expect(snap.scrollHeight).toBe(20) + }) }) diff --git a/ui-tui/src/domain/viewport.ts b/ui-tui/src/domain/viewport.ts index 48d7427fd..4fdbfcc93 100644 --- a/ui-tui/src/domain/viewport.ts +++ b/ui-tui/src/domain/viewport.ts @@ -26,21 +26,25 @@ export const stickyPromptFromViewport = ( return '' } - const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1)) - const last = Math.max(first, Math.min(messages.length - 1, upperBound(offsets, bottom) - 1)) + const first = Math.max(0, upperBound(offsets, top) - 1) + const last = Math.max(first, upperBound(offsets, bottom) - 1) + const visibleStart = Math.min(messages.length, first) + const visibleEnd = Math.min(messages.length - 1, last) - for (let i = first; i <= last; i++) { + for (let i = visibleStart; i <= visibleEnd; i++) { if (messages[i]?.role === 'user') { return '' } } - for (let i = first - 1; i >= 0; i--) { + for (let i = Math.min(messages.length - 1, visibleStart - 1); i >= 0; i--) { if (messages[i]?.role !== 'user') { continue } - return (offsets[i] ?? 0) + 1 < top ? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() : '' + return (offsets[i + 1] ?? (offsets[i] ?? 0) + 1) <= top + ? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() + : '' } return '' diff --git a/ui-tui/src/lib/viewportStore.ts b/ui-tui/src/lib/viewportStore.ts index 0281e059b..b25ef581f 100644 --- a/ui-tui/src/lib/viewportStore.ts +++ b/ui-tui/src/lib/viewportStore.ts @@ -28,11 +28,18 @@ export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapsho const pending = s.getPendingDelta() const top = Math.max(0, s.getScrollTop() + pending) const viewportHeight = Math.max(0, s.getViewportHeight()) - const scrollHeight = Math.max(viewportHeight, s.getScrollHeight()) + const cachedScrollHeight = Math.max(viewportHeight, s.getScrollHeight()) + let scrollHeight = cachedScrollHeight const bottom = top + viewportHeight + let atBottom = s.isSticky() || bottom >= scrollHeight - 2 + + if (!atBottom) { + scrollHeight = Math.max(viewportHeight, s.getFreshScrollHeight?.() ?? cachedScrollHeight) + atBottom = s.isSticky() || bottom >= scrollHeight - 2 + } return { - atBottom: s.isSticky() || bottom >= scrollHeight - 2, + atBottom, bottom, pending, scrollHeight, diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 4a0bd75f1..c8038576d 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -83,6 +83,7 @@ declare module '@hermes/ink' { readonly getScrollTop: () => number readonly getPendingDelta: () => number readonly getScrollHeight: () => number + readonly getFreshScrollHeight: () => number readonly getViewportHeight: () => number readonly getViewportTop: () => number readonly getLastManualScrollAt: () => number