diff --git a/ui-tui/src/__tests__/textInputWrap.test.ts b/ui-tui/src/__tests__/textInputWrap.test.ts index 9414b9fbdb..170f6883aa 100644 --- a/ui-tui/src/__tests__/textInputWrap.test.ts +++ b/ui-tui/src/__tests__/textInputWrap.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' -import { cursorLayout, offsetFromPosition } from '../components/textInput.js' +import { offsetFromPosition } from '../components/textInput.js' +import { cursorLayout, inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js' describe('cursorLayout — char-wrap parity with wrap-ansi', () => { it('places cursor mid-line at its column', () => { @@ -35,6 +36,18 @@ describe('cursorLayout — char-wrap parity with wrap-ansi', () => { }) }) +describe('input metrics helpers', () => { + it('computes visual height from the wrapped cursor line', () => { + expect(inputVisualHeight('abcdefgh', 8)).toBe(2) + expect(inputVisualHeight('one\ntwo', 40)).toBe(2) + }) + + it('reserves a stable transcript scrollbar gutter for composer width', () => { + expect(stableComposerColumns(100, 3)).toBe(93) + expect(stableComposerColumns(10, 3)).toBe(20) + }) +}) + describe('offsetFromPosition — char-wrap inverse of cursorLayout', () => { it('returns 0 for empty input', () => { expect(offsetFromPosition('', 0, 0, 10)).toBe(0) diff --git a/ui-tui/src/__tests__/viewportStore.test.ts b/ui-tui/src/__tests__/viewportStore.test.ts new file mode 100644 index 0000000000..671ef9cfed --- /dev/null +++ b/ui-tui/src/__tests__/viewportStore.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' + +import { getViewportSnapshot, viewportSnapshotKey } from '../lib/viewportStore.js' + +describe('viewportStore', () => { + it('normalizes absent scroll handles', () => { + expect(getViewportSnapshot(null)).toEqual({ + atBottom: true, + bottom: 0, + pending: 0, + scrollHeight: 0, + top: 0, + viewportHeight: 0 + }) + }) + + it('includes pending scroll delta in snapshot math and keying', () => { + const handle = { + getPendingDelta: () => 3, + getScrollHeight: () => 40, + getScrollTop: () => 10, + getViewportHeight: () => 5, + isSticky: () => false + } + + const snap = getViewportSnapshot(handle as any) + + expect(snap).toMatchObject({ atBottom: false, bottom: 18, pending: 3, scrollHeight: 40, top: 13, viewportHeight: 5 }) + expect(viewportSnapshotKey(snap)).toBe('0:13:5:40:3') + }) +}) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 0230e0b1fd..31f228eb6b 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -19,6 +19,7 @@ import { useVirtualHistory } from '../hooks/useVirtualHistory.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { terminalParityHints } from '../lib/terminalParity.js' import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' +import { getViewportSnapshot } from '../lib/viewportStore.js' import type { Msg, PanelSection, SlashCatalog } from '../types.js' import { createGatewayEventHandler } from './createGatewayEventHandler.js' @@ -689,11 +690,9 @@ export function useMainApp(gw: GatewayClient) { return true } - const top = Math.max(0, s.getScrollTop() + s.getPendingDelta()) - const vp = Math.max(0, s.getViewportHeight()) - const total = Math.max(vp, s.getScrollHeight()) + const { bottom, scrollHeight } = getViewportSnapshot(s) - return top + vp >= total - 3 + return bottom >= scrollHeight - 3 })() const liveProgress = useMemo( diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 001c89b91f..6085df8a10 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -1,6 +1,6 @@ import { Box, type ScrollBoxHandle, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' -import { type ReactNode, type RefObject, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react' +import { type ReactNode, type RefObject, useEffect, useMemo, useState } from 'react' import { $delegationState } from '../app/delegationStore.js' import { $turnState } from '../app/turnStore.js' @@ -9,6 +9,7 @@ import { VERBS } from '../content/verbs.js' import { fmtDuration } from '../domain/messages.js' import { stickyPromptFromViewport } from '../domain/viewport.js' import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js' +import { useViewportSnapshot } from '../lib/viewportStore.js' import { fmtK } from '../lib/text.js' import type { Theme } from '../theme.js' import type { Msg, Usage } from '../types.js' @@ -255,17 +256,7 @@ export function FloatBox({ children, color }: { children: ReactNode; color: stri } export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: StickyPromptTrackerProps) { - useSyncExternalStore( - useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), - () => { - const { atBottom, top } = getStickyViewport(scrollRef.current) - - return atBottom ? -1 - top : top - }, - () => NaN - ) - - const { atBottom, bottom, top } = getStickyViewport(scrollRef.current) + const { atBottom, bottom, top } = useViewportSnapshot(scrollRef) const text = stickyPromptFromViewport(messages, offsets, top, bottom, atBottom) useEffect(() => onChange(text), [onChange, text]) @@ -274,42 +265,18 @@ export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: } export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) { - useSyncExternalStore( - useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), - () => { - const s = scrollRef.current - - if (!s) { - return NaN - } - - const vp = Math.max(0, s.getViewportHeight()) - const total = Math.max(vp, s.getScrollHeight()) - const top = Math.max(0, s.getScrollTop() + s.getPendingDelta()) - const thumb = total > vp ? Math.max(1, Math.round((vp * vp) / total)) : vp - const travel = Math.max(1, vp - thumb) - const thumbTop = total > vp ? Math.round((top / Math.max(1, total - vp)) * travel) : 0 - - return `${thumbTop}:${thumb}:${vp}` - }, - () => '' - ) - const [hover, setHover] = useState(false) const [grab, setGrab] = useState(null) - - const s = scrollRef.current - const vp = Math.max(0, s?.getViewportHeight() ?? 0) + const { scrollHeight: total, top: pos, viewportHeight: vp } = useViewportSnapshot(scrollRef) if (!vp) { return } - const total = Math.max(vp, s?.getScrollHeight() ?? vp) + const s = scrollRef.current const scrollable = total > vp const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp const travel = Math.max(1, vp - thumb) - const pos = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0)) const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0 const thumbColor = grab !== null ? t.color.gold : hover ? t.color.amber : t.color.bronze const trackColor = hover ? t.color.bronze : t.color.dim @@ -391,15 +358,3 @@ interface TranscriptScrollbarProps { scrollRef: RefObject t: Theme } - -function getStickyViewport(s?: ScrollBoxHandle | null) { - const top = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0)) - const vp = Math.max(0, s?.getViewportHeight() ?? 0) - const total = Math.max(vp, s?.getScrollHeight() ?? vp) - - return { - atBottom: (s?.isSticky() ?? true) || top + vp >= total - 2, - bottom: top + vp, - top - } -} diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index d856451751..170d0649ac 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -7,6 +7,7 @@ import type { AppLayoutProgressProps, AppLayoutProps } from '../app/interfaces.j import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js' import { $uiState } from '../app/uiStore.js' import { PLACEHOLDER } from '../content/placeholders.js' +import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js' import type { Theme } from '../theme.js' import type { DetailsMode, SectionVisibility } from '../types.js' @@ -171,6 +172,8 @@ const ComposerPane = memo(function ComposerPane({ const isBlocked = useStore($isBlocked) const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!') const pw = sh ? 2 : 3 + const inputColumns = stableComposerColumns(composer.cols, pw) + const inputHeight = inputVisualHeight(composer.input, inputColumns) return ( @@ -232,10 +235,10 @@ const ComposerPane = memo(function ComposerPane({ )} - - {/* subtract NoSelect paddingX={1} (2 cols) + pw so wrap-ansi and cursorLayout agree */} + + {/* Reserve the transcript scrollbar gutter too so typing never rewraps when the scrollbar column repaints. */} actually renders -export function cursorLayout(value: string, cursor: number, cols: number) { - const pos = Math.max(0, Math.min(cursor, value.length)) - const w = Math.max(1, cols) - - let col = 0, - line = 0 - - for (const { segment, index } of seg().segment(value)) { - if (index >= pos) { - break - } - - if (segment === '\n') { - line++ - col = 0 - - continue - } - - const sw = stringWidth(segment) - - if (!sw) { - continue - } - - if (col + sw > w) { - line++ - col = 0 - } - - col += sw - } - - // trailing cursor-cell overflows to the next row at the wrap column - if (col >= w) { - line++ - col = 0 - } - - return { column: col, line } -} - export function offsetFromPosition(value: string, row: number, col: number, cols: number) { if (!value.length) { return 0 diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index 17bc8dfd3e..388b5e5a48 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -167,8 +167,20 @@ export function useVirtualHistory( }, []) useLayoutEffect(() => { + const s = scrollRef.current let dirty = false + // Give the renderer the mounted-row coverage for passive scroll clamping. + // Without this, burst wheel/page scroll can race past the React commit that + // updates the virtual range and paint spacer-only frames. + if (s && n > 0 && vp > 0) { + const min = offsets[start] ?? 0 + const max = Math.max(min, (offsets[end] ?? total) - vp) + s.setClampBounds(min, max) + } else { + s?.setClampBounds(undefined, undefined) + } + if (skipMeasurement.current) { skipMeasurement.current = false } else { @@ -188,8 +200,6 @@ export function useVirtualHistory( } } - const s = scrollRef.current - if (s) { const next = { sticky: s.isSticky(), @@ -210,7 +220,7 @@ export function useVirtualHistory( if (dirty) { setVer(v => v + 1) } - }, [end, hasScrollRef, items, scrollRef, start]) + }, [end, hasScrollRef, items, n, offsets, scrollRef, start, total, vp]) return { bottomSpacer: Math.max(0, total - (offsets[end] ?? total)), diff --git a/ui-tui/src/lib/inputMetrics.ts b/ui-tui/src/lib/inputMetrics.ts new file mode 100644 index 0000000000..a42dbb2fbc --- /dev/null +++ b/ui-tui/src/lib/inputMetrics.ts @@ -0,0 +1,62 @@ +import { stringWidth } from '@hermes/ink' + +let _seg: Intl.Segmenter | null = null +const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' })) + +/** + * Mirrors the char-wrap behavior used by the composer TextInput. + * Returns the zero-based visual line and column of the cursor cell. + */ +export function cursorLayout(value: string, cursor: number, cols: number) { + const pos = Math.max(0, Math.min(cursor, value.length)) + const w = Math.max(1, cols) + + let col = 0, + line = 0 + + for (const { segment, index } of seg().segment(value)) { + if (index >= pos) { + break + } + + if (segment === '\n') { + line++ + col = 0 + + continue + } + + const sw = stringWidth(segment) + + if (!sw) { + continue + } + + if (col + sw > w) { + line++ + col = 0 + } + + col += sw + } + + // trailing cursor-cell overflows to the next row at the wrap column + if (col >= w) { + line++ + col = 0 + } + + return { column: col, line } +} + +export function inputVisualHeight(value: string, columns: number) { + return cursorLayout(value, value.length, columns).line + 1 +} + +export function stableComposerColumns(totalCols: number, promptWidth: number) { + // totalCols is the terminal width. Reserve: + // - outer composer paddingX={1}: 2 columns + // - transcript scrollbar gutter + marginLeft: 2 columns + // - prompt prefix width + return Math.max(20, totalCols - promptWidth - 4) +} diff --git a/ui-tui/src/lib/viewportStore.ts b/ui-tui/src/lib/viewportStore.ts new file mode 100644 index 0000000000..30028454c4 --- /dev/null +++ b/ui-tui/src/lib/viewportStore.ts @@ -0,0 +1,59 @@ +import type { RefObject } from 'react' +import { useCallback, useSyncExternalStore } from 'react' + +import type { ScrollBoxHandle } from '@hermes/ink' + +export interface ViewportSnapshot { + atBottom: boolean + bottom: number + pending: number + scrollHeight: number + top: number + viewportHeight: number +} + +const EMPTY: ViewportSnapshot = { + atBottom: true, + bottom: 0, + pending: 0, + scrollHeight: 0, + top: 0, + viewportHeight: 0 +} + +export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapshot { + if (!s) { + return EMPTY + } + + 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 bottom = top + viewportHeight + + return { + atBottom: s.isSticky() || bottom >= scrollHeight - 2, + bottom, + pending, + scrollHeight, + top, + viewportHeight + } +} + +export function viewportSnapshotKey(v: ViewportSnapshot) { + return `${v.atBottom ? 1 : 0}:${v.top}:${v.viewportHeight}:${v.scrollHeight}:${v.pending}` +} + +export function useViewportSnapshot(scrollRef: RefObject): ViewportSnapshot { + const key = useSyncExternalStore( + useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), + () => viewportSnapshotKey(getViewportSnapshot(scrollRef.current)), + () => viewportSnapshotKey(EMPTY) + ) + + void key + + return getViewportSnapshot(scrollRef.current) +} diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 507be85a34..344833ba18 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -59,6 +59,7 @@ declare module '@hermes/ink' { readonly getViewportTop: () => number readonly isSticky: () => boolean readonly subscribe: (listener: () => void) => () => void + readonly setClampBounds: (min: number | undefined, max: number | undefined) => void } export const Box: React.ComponentType