diff --git a/ui-tui/src/__tests__/viewport.test.ts b/ui-tui/src/__tests__/viewport.test.ts index d8500c8d20..eca079470d 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 16031c9672..7889b65cde 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 48d7427fd1..4fdbfcc930 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 0281e059b9..b25ef581f4 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 4a0bd75f1c..c8038576d3 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