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.
This commit is contained in:
Brooklyn Nicholson 2026-04-28 22:10:40 -05:00
parent afb20a1d67
commit ce2cc7302e
5 changed files with 62 additions and 7 deletions

View file

@ -28,4 +28,31 @@ describe('stickyPromptFromViewport', () => {
expect(stickyPromptFromViewport(messages, offsets, 16, 20, false)).toBe('current prompt') 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('')
})
}) })

View file

@ -35,4 +35,20 @@ describe('viewportStore', () => {
}) })
expect(viewportSnapshotKey(snap)).toBe('0:16:5:40:3') 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)
})
}) })

View file

@ -26,21 +26,25 @@ export const stickyPromptFromViewport = (
return '' return ''
} }
const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1)) const first = Math.max(0, upperBound(offsets, top) - 1)
const last = Math.max(first, Math.min(messages.length - 1, upperBound(offsets, bottom) - 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') { if (messages[i]?.role === 'user') {
return '' 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') { if (messages[i]?.role !== 'user') {
continue 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 '' return ''

View file

@ -28,11 +28,18 @@ export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapsho
const pending = s.getPendingDelta() const pending = s.getPendingDelta()
const top = Math.max(0, s.getScrollTop() + pending) const top = Math.max(0, s.getScrollTop() + pending)
const viewportHeight = Math.max(0, s.getViewportHeight()) 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 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 { return {
atBottom: s.isSticky() || bottom >= scrollHeight - 2, atBottom,
bottom, bottom,
pending, pending,
scrollHeight, scrollHeight,

View file

@ -83,6 +83,7 @@ declare module '@hermes/ink' {
readonly getScrollTop: () => number readonly getScrollTop: () => number
readonly getPendingDelta: () => number readonly getPendingDelta: () => number
readonly getScrollHeight: () => number readonly getScrollHeight: () => number
readonly getFreshScrollHeight: () => number
readonly getViewportHeight: () => number readonly getViewportHeight: () => number
readonly getViewportTop: () => number readonly getViewportTop: () => number
readonly getLastManualScrollAt: () => number readonly getLastManualScrollAt: () => number