diff --git a/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx index aac8f2b334..ed4239cef0 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx @@ -257,6 +257,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren< if (el) { el.scrollTop ??= 0 + el.notifyScrollChange = notify } }} style={{ diff --git a/ui-tui/packages/hermes-ink/src/ink/dom.ts b/ui-tui/packages/hermes-ink/src/ink/dom.ts index 6c4b198304..735ab0b0c5 100644 --- a/ui-tui/packages/hermes-ink/src/ink/dom.ts +++ b/ui-tui/packages/hermes-ink/src/ink/dom.ts @@ -72,6 +72,7 @@ export type DOMElement = { scrollViewportHeight?: number scrollViewportTop?: number stickyScroll?: boolean + notifyScrollChange?: () => void // Set by ScrollBox.scrollToElement; render-node-to-output reads // el.yogaNode.getComputedTop() (FRESH — same Yoga pass as scrollHeight) // and sets scrollTop = top + offset, then clears this. Unlike an diff --git a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts index dd7372a092..12d689c166 100644 --- a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts +++ b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts @@ -761,6 +761,7 @@ function renderNodeToOutput( // active text selection by the same delta (native terminal behavior: // view keeps scrolling, highlight walks up with the text). const scrollTopBeforeFollow = node.scrollTop ?? 0 + const stickyBeforeFollow = node.stickyScroll const sticky = node.stickyScroll ?? Boolean(node.attributes['stickyScroll']) @@ -863,6 +864,10 @@ function renderNodeToOutput( scrollDrainNode = node } + if ((node.scrollTop ?? 0) !== scrollTopBeforeFollow || node.stickyScroll !== stickyBeforeFollow) { + node.notifyScrollChange?.() + } + scrollTop = clamped if (content && contentYoga) { diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index d12a4debff..8b1f816ce4 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -256,15 +256,21 @@ export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: } const top = Math.max(0, s.getScrollTop() + s.getPendingDelta()) + const vp = Math.max(0, s.getViewportHeight()) + const total = Math.max(vp, s.getScrollHeight()) + const atBottom = s.isSticky() || top + vp >= total - 2 - return s.isSticky() ? -1 - top : top + return atBottom ? -1 - top : top }, () => NaN ) const s = scrollRef.current const top = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0)) - const text = stickyPromptFromViewport(messages, offsets, top, s?.isSticky() ?? true) + const vp = Math.max(0, s?.getViewportHeight() ?? 0) + const total = Math.max(vp, s?.getScrollHeight() ?? vp) + const atBottom = (s?.isSticky() ?? true) || top + vp >= total - 2 + const text = stickyPromptFromViewport(messages, offsets, top, atBottom) useEffect(() => onChange(text), [onChange, text])