fix(tui): refresh virtual transcript on viewport resize (#31077)

* fix(tui): refresh virtual transcript on viewport resize

Notify scroll subscribers when ScrollBox viewport bounds change and key virtual-history updates on viewport height so resize/keyboard changes remount the tail rows instead of leaving stale spacers visible.

* test(tui): isolate viewport-height remount regression

Keep the resize delta below the virtual history scroll quantum so the regression test specifically depends on viewport height entering the snapshot key.

* test(tui): clarify virtual history resize snapshot

Update the resize regression and comments so the test specifically guards viewport-height changes in the virtual-history snapshot key.

* docs(tui): clarify scrollbox subscription signals

Document that ScrollBox subscribers are notified for renderer-computed viewport and content bound changes, not only imperative scrolls.

* fix(tui): recompute virtual tail after width resize

Avoid preserving a frozen virtual transcript range when wrapped rows shrink enough that the old tail window no longer covers the viewport.

* fix(tui): preserve transcript tail across resizes

Wraps + heights are column-dependent, so a width change must remeasure
every row and the renderer must repaint the full viewport.

- Key virtualRows on cols so React remounts wrapped rows on resize.
- Snap back to bottom after sticky-mode resize once React rerenders.
- Reserve a scrollbar + gap column in transcriptBodyWidth (non-termux).
- Full repaint on any viewport height change (was: shrink-only).
- ScrollBox scrollHeight uses deepest child bottom so sticky-bottom
  math can reach the real final rendered row after reflow.
- DECSTBM fast-path now requires full container rect match.

* feat(tui): responsive banner tiers

Terminals can't scale glyphs, so the banner now picks a layout per
column width instead of always rendering the full 101-col logo:

- Wide (>= logo width): full ASCII logo + tagline.
- Mid (>= 58 cols): centered rule banner that expands with viewport.
- Narrow (>= 34 cols): brand line + tagline, both width-aware.
- < 34 cols: hidden.

SessionPanel surfaces model/cwd/sid inline when the hero column is
hidden, so narrow layouts don't lose that info. Logo width constants
derive from the art itself.

* fix(tui): re-check sticky inside resize debounce + document remount

Addresses Copilot review on PR #31077:

- onResize now re-checks isSticky() inside the 100ms timer so manual
  scrolls during the debounce window don't get snapped back to tail.
- Comment on the virtualRows cols-keying calls out the deliberate
  trade-off: per-row local state (e.g. systemOpen) resets on resize so
  yoga can remeasure off live geometry. The hook's scale-by-ratio path
  is too approximate for mixed markdown widths.
This commit is contained in:
brooklyn! 2026-05-23 19:39:53 -05:00 committed by GitHub
commit f63ef74eaf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 367 additions and 77 deletions

View file

@ -48,10 +48,10 @@ export type ScrollBoxHandle = {
*/
isSticky: () => boolean
/**
* Subscribe to imperative scroll changes (scrollTo/scrollBy/scrollToBottom).
* Does NOT fire for stickyScroll updates done by the Ink renderer those
* happen during Ink's render phase after React has committed. Callers that
* care about the sticky case should treat "at bottom" as a fallback.
* Subscribe to scroll viewport changes. Fires for imperative scroll changes
* (scrollTo/scrollBy/scrollToBottom) and for renderer-computed scroll bounds
* changes such as content growth or terminal resize. Callers use this to
* keep virtualized ranges aligned with the currently visible viewport.
*/
subscribe: (listener: () => void) => () => void
/**

View file

@ -42,7 +42,8 @@ const stdoutOnly = (diff: ReturnType<LogUpdate['render']>) =>
.map(p => (p as { type: 'stdout'; content: string }).content)
.join('')
const hasDecstbm = (text: string) => /\x1b\[\d+;\d+r/.test(text)
const ESC = '\u001b'
const hasDecstbm = (text: string) => new RegExp(`${ESC}\\[\\d+;\\d+r`).test(text)
describe('LogUpdate.render diff contract', () => {
it('emits only changed cells when most rows match', () => {
@ -87,6 +88,25 @@ describe('LogUpdate.render diff contract', () => {
expect(stdoutOnly(diff)).toContain('shorterrownow')
})
it('height growth emits a clearTerminal patch before repainting', () => {
const w = 20
const prevH = 3
const nextH = 6
const prev = mkScreen(w, prevH)
paint(prev, 0, 'old rows')
const next = mkScreen(w, nextH)
paint(next, 0, 'new rows')
next.damage = { x: 0, y: 0, width: w, height: nextH }
const log = new LogUpdate({ isTTY: true, stylePool })
const diff = log.render(mkFrame(prev, w, prevH), mkFrame(next, w, nextH), true, false)
expect(diff.some(p => p.type === 'clearTerminal')).toBe(true)
expect(stdoutOnly(diff)).toContain('newrows')
})
it('drift repro: identical prev/next emits no heal, even when the physical terminal is stale', () => {
// Load-bearing theory for the rapid-resize scattered-letter bug: if the
// physical terminal has stale cells that prev.screen doesn't know about
@ -167,10 +187,12 @@ describe('LogUpdate.render diff contract', () => {
paint(next, 1, 'row one')
const prevFrame = mkFrame(prev, w, h)
const nextFrame: Frame = {
...mkFrame(next, w, h),
scrollHint: { top: 1, bottom: 4, delta: 1 }
}
const log = new LogUpdate({ isTTY: true, stylePool })
const diff = log.render(prevFrame, nextFrame, true, true)
@ -187,10 +209,12 @@ describe('LogUpdate.render diff contract', () => {
paint(next, 1, 'row one')
const prevFrame = mkFrame(prev, w, h)
const nextFrame: Frame = {
...mkFrame(next, w, h),
scrollHint: { top: 1, bottom: 5, delta: 1 }
}
const log = new LogUpdate({ isTTY: true, stylePool })
const diff = log.render(prevFrame, nextFrame, true, true)

View file

@ -141,14 +141,12 @@ export class LogUpdate {
const startTime = performance.now()
const stylePool = this.options.stylePool
// Since we assume the cursor is at the bottom on the screen, we only need
// to clear when the viewport gets shorter (i.e. the cursor position drifts)
// or when it gets thinner (and text wraps). We _could_ figure out how to
// not reset here but that would involve predicting the current layout
// _after_ the viewport change which means calcuating text wrapping.
// Resizing is a rare enough event that it's not practically a big issue.
// Terminal hosts can reflow/preserve old cells on any resize, including
// height-only growth. A partial diff can then leave stale transcript rows
// or cut off bordered content even when our virtual scrollTop is correct.
// Resizing is rare enough that a full repaint is the safer tradeoff.
if (
next.viewport.height < prev.viewport.height ||
next.viewport.height !== prev.viewport.height ||
(prev.viewport.width !== 0 && next.viewport.width !== prev.viewport.width)
) {
return fullResetSequence_CAUSES_FLICKER(next, 'resize', stylePool)

View file

@ -706,12 +706,22 @@ function renderNodeToOutput(
const content = node.childNodes.find(c => (c as DOMElement).yogaNode) as DOMElement | undefined
const contentYoga = content?.yogaNode
// scrollHeight is the intrinsic height of the content wrapper.
// Do NOT add getComputedTop() — that's the wrapper's offset
// within the viewport (equal to the scroll container's
// paddingTop), and innerHeight already subtracts padding, so
// including it double-counts padding and inflates maxScroll.
const scrollHeight = contentYoga?.getComputedHeight() ?? 0
// scrollHeight is the intrinsic height of the content wrapper, but
// after terminal resizes Yoga can leave tall descendants overflowing
// that wrapper. Use the deepest direct child bottom so sticky-bottom
// math can still reach the real final rendered row.
let scrollHeight = Math.ceil(contentYoga?.getComputedHeight() ?? 0)
if (content) {
for (const child of content.childNodes) {
const childYoga = (child as DOMElement).yogaNode
if (childYoga) {
scrollHeight = Math.max(scrollHeight, Math.ceil(childYoga.getComputedTop() + childYoga.getComputedHeight()))
}
}
}
// Capture previous scroll bounds BEFORE overwriting — the at-bottom
// follow check compares against last frame's max.
const prevScrollHeight = node.scrollHeight ?? scrollHeight
@ -862,7 +872,12 @@ function renderNodeToOutput(
scrollDrainNode = node
}
if ((node.scrollTop ?? 0) !== scrollTopBeforeFollow || node.stickyScroll !== stickyBeforeFollow) {
if (
(node.scrollTop ?? 0) !== scrollTopBeforeFollow ||
node.stickyScroll !== stickyBeforeFollow ||
scrollHeight !== prevScrollHeight ||
innerHeight !== prevInnerHeight
) {
node.notifyScrollChange?.()
}
@ -891,7 +906,14 @@ function renderNodeToOutput(
const regionTop = Math.floor(y + contentYoga.getComputedTop())
const regionBottom = regionTop + innerHeight - 1
if (cached?.y === y && cached.height === height && innerHeight > 0 && Math.abs(delta) < innerHeight) {
if (
cached?.x === x &&
cached.y === y &&
cached.width === width &&
cached.height === height &&
innerHeight > 0 &&
Math.abs(delta) < innerHeight
) {
hint = { top: regionTop, bottom: regionBottom, delta }
scrollHint = hint
} else {

View file

@ -4,10 +4,11 @@ import { Box, renderSync, ScrollBox, type ScrollBoxHandle, Text } from '@hermes/
import React, { useLayoutEffect, useRef } from 'react'
import { describe, expect, it } from 'vitest'
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
import { useVirtualHistory, virtualHistorySnapshotKey } from '../hooks/useVirtualHistory.js'
interface Item {
height: number
heightAfterResize?: number
key: string
}
@ -49,13 +50,28 @@ const viewportIsMounted = (items: readonly Item[], virtualHistory: ReturnType<ty
return top >= span.top && bottom <= span.bottom
}
function Harness({ expose, items }: { expose: React.MutableRefObject<Exposed | null>; items: readonly Item[] }) {
const itemHeightForColumns = (item: Item | undefined, columns: number) =>
columns >= 80 ? (item?.heightAfterResize ?? item?.height ?? 1) : (item?.height ?? 1)
function Harness({
columns = 80,
expose,
height = 10,
items,
maxMounted = 16
}: {
columns?: number
expose: React.MutableRefObject<Exposed | null>
height?: number
items: readonly Item[]
maxMounted?: number
}) {
const scrollRef = useRef<ScrollBoxHandle | null>(null)
const virtualHistory = useVirtualHistory(scrollRef, items, 80, {
const virtualHistory = useVirtualHistory(scrollRef, items, columns, {
coldStartCount: 16,
estimateHeight: index => items[index]?.height ?? 1,
maxMounted: 16,
estimateHeight: index => itemHeightForColumns(items[index], columns),
maxMounted,
overscan: 2
})
@ -65,7 +81,7 @@ function Harness({ expose, items }: { expose: React.MutableRefObject<Exposed | n
return React.createElement(
ScrollBox,
{ flexDirection: 'column', height: 10, ref: scrollRef, stickyScroll: true },
{ flexDirection: 'column', height, ref: scrollRef, stickyScroll: true },
React.createElement(
Box,
{ flexDirection: 'column', width: '100%' },
@ -75,7 +91,11 @@ function Harness({ expose, items }: { expose: React.MutableRefObject<Exposed | n
.map(item =>
React.createElement(
Box,
{ height: item.height, key: item.key, ref: virtualHistory.measureRef(item.key) },
{
height: itemHeightForColumns(item, columns),
key: item.key,
ref: virtualHistory.measureRef(item.key)
},
React.createElement(Text, null, item.key)
)
),
@ -85,6 +105,113 @@ function Harness({ expose, items }: { expose: React.MutableRefObject<Exposed | n
}
describe('useVirtualHistory offset cache reuse', () => {
it('includes viewport height in the external-store snapshot key', () => {
const base = {
getPendingDelta: () => 0,
getScrollTop: () => 20,
isSticky: () => false
}
const short = virtualHistorySnapshotKey({
...base,
getViewportHeight: () => 5
} as ScrollBoxHandle)
const tall = virtualHistorySnapshotKey({
...base,
getViewportHeight: () => 25
} as ScrollBoxHandle)
expect(short).not.toBe(tall)
})
it('remounts enough tail rows after the scroll viewport grows', async () => {
const items = Array.from({ length: 100 }, (_, index) => ({ height: 1, key: `item-${index}` }))
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const instance = renderSync(React.createElement(Harness, { expose, height: 4, items, maxMounted: 80 }), {
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
})
try {
await delay(20)
instance.rerender(React.createElement(Harness, { expose, height: 9, items, maxMounted: 80 }))
await delay(80)
expect(viewportIsMounted(items, expose.current!.virtualHistory, expose.current!.scroll!)).toBe(true)
} finally {
instance.unmount()
instance.cleanup()
}
})
it('recomputes tail coverage when wrapped rows shrink after a width resize', async () => {
const items = Array.from({ length: 100 }, (_, index) => ({
height: 4,
heightAfterResize: 1,
key: `item-${index}`
}))
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const instance = renderSync(
React.createElement(Harness, { columns: 40, expose, height: 10, items, maxMounted: 80 }),
{
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
}
)
try {
await delay(20)
instance.rerender(React.createElement(Harness, { columns: 80, expose, height: 10, items, maxMounted: 80 }))
await delay(80)
const resizedItems = items.map(item => ({ height: item.heightAfterResize!, key: item.key }))
expect(viewportIsMounted(resizedItems, expose.current!.virtualHistory, expose.current!.scroll!)).toBe(true)
} finally {
instance.unmount()
instance.cleanup()
}
})
it('keeps sticky scroll at the bottom when one tall tail row resizes', async () => {
const items = [{ height: 90, heightAfterResize: 50, key: 'tail' }]
const expose = { current: null as Exposed | null }
const streams = makeStreams()
const instance = renderSync(
React.createElement(Harness, { columns: 70, expose, height: 18, items, maxMounted: 80 }),
{
patchConsole: false,
stderr: streams.stderr as NodeJS.WriteStream,
stdin: streams.stdin as NodeJS.ReadStream,
stdout: streams.stdout as NodeJS.WriteStream
}
)
try {
await delay(20)
instance.rerender(React.createElement(Harness, { columns: 120, expose, height: 36, items, maxMounted: 80 }))
await delay(80)
const scroll = expose.current!.scroll!
expect(scroll.getScrollTop()).toBe(scroll.getScrollHeight() - scroll.getViewportHeight())
} finally {
instance.unmount()
instance.cleanup()
}
})
it('recomputes offsets after a mounted row height changes', async () => {
const tall = [
{ height: 6, key: 'a' },

View file

@ -234,9 +234,15 @@ export function useMainApp(gw: GatewayClient) {
return next
}, [])
// Wrapped row heights are width-dependent. Cached layout outlives a resize
// and lands sticky-scroll at the stale max, cutting off the tail. The
// hook's "scale heights by oldCols/newCols" path is too approximate for
// mixed markdown — we deliberately remount every row so yoga re-measures
// off live geometry. Cost: per-row local state (e.g. systemOpen toggles)
// resets on resize; small UX hit for a hard correctness win.
const virtualRows = useMemo<TranscriptRow[]>(
() => historyItems.map((msg, index) => ({ index, key: messageId(msg), msg })),
[historyItems, messageId]
() => historyItems.map((msg, index) => ({ index, key: `${messageId(msg)}:c${cols}`, msg })),
[cols, historyItems, messageId]
)
const detailsLayoutKey = useMemo(() => {
@ -425,10 +431,20 @@ export function useMainApp(gw: GatewayClient) {
let timer: ReturnType<typeof setTimeout> | undefined
// Resize reflows wrapped lines; if the user is still pinned to the tail
// we need to re-snap once React has remeasured. virtualRows is keyed on
// cols so every column change forces a fresh measurement pass before
// this timer fires. Re-check isSticky() inside the timeout — a manual
// scroll during the 100ms window otherwise yanks the user back to tail.
const onResize = () => {
clearTimeout(timer)
timer = setTimeout(() => {
timer = undefined
if (scrollRef.current?.isSticky()) {
scrollRef.current.scrollToBottom()
}
void rpc<TerminalResizeResponse>('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid })
}, 100)
}

View file

@ -79,8 +79,8 @@ const colorize = (art: string[], gradient: readonly number[], c: ThemeColors): L
return art.map((text, i) => [p[gradient[i]!] ?? c.muted, text])
}
export const LOGO_WIDTH = 98
export const CADUCEUS_WIDTH = 30
export const LOGO_WIDTH = Math.max(...LOGO_ART.map(line => line.length))
export const CADUCEUS_WIDTH = Math.max(...CADUCEUS_ART.map(line => line.length))
export const logo = (c: ThemeColors, customLogo?: string): Line[] =>
customLogo ? parseRichMarkup(customLogo) : colorize(LOGO_ART, LOGO_GRADIENT, c)

View file

@ -112,9 +112,9 @@ const TranscriptPane = memo(function TranscriptPane({
{row.msg.kind === 'intro' ? (
<Box flexDirection="column" paddingTop={1}>
<Banner t={ui.theme} />
<Banner maxWidth={Math.max(1, composer.cols - 2)} t={ui.theme} />
{row.msg.info && <SessionPanel info={row.msg.info} sid={ui.sid} t={ui.theme} />}
{row.msg.info && <SessionPanel info={row.msg.info} maxWidth={Math.max(1, composer.cols - 2)} sid={ui.sid} t={ui.theme} />}
</Box>
) : row.msg.kind === 'panel' && row.msg.panelData ? (
<Panel sections={row.msg.panelData.sections} t={ui.theme} title={row.msg.panelData.title} />

View file

@ -29,31 +29,92 @@ function InlineLoader({ label, t }: { label: string; t: Theme }) {
export function ArtLines({ lines }: { lines: [string, string][] }) {
return (
<>
<Box flexDirection="column" height={lines.length} opaque width={artWidth(lines)}>
{lines.map(([c, text], i) => (
<Text color={c} key={i}>
<Text color={c} key={i} wrap="truncate-end">
{text}
</Text>
))}
</>
</Box>
)
}
export function Banner({ t }: { t: Theme }) {
const cols = useStdout().stdout?.columns ?? 80
// Responsive Banner: full art → compact rule → text → hidden.
//
// Terminals can't scale glyphs, so "responsive" means picking a layout that
// fits the available columns. Thresholds are picked so each tier reads
// comfortably without forcing wrap or truncation drift on box-drawing edges.
const TAG_FULL = 'Nous Research · Messenger of the Digital Gods'
const TAG_MID = 'Messenger of the Digital Gods'
const TAG_TINY = 'Nous Research'
const HIDE_BELOW = 34
const COMPACT_FROM = 58
const clip = (s: string, w: number) =>
w <= 0 ? '' : s.length > w ? `${s.slice(0, Math.max(0, w - 1))}` : s
const centerIn = (s: string, w: number) => {
const f = clip(s, w)
const slack = Math.max(0, w - f.length)
const left = slack >> 1
return `${' '.repeat(left)}${f}${' '.repeat(slack - left)}`
}
const ruleIn = (label: string, w: number) => {
const f = clip(label, Math.max(1, w - 4))
const slack = Math.max(0, w - f.length - 2)
const left = slack >> 1
return `${'─'.repeat(left)} ${f} ${'─'.repeat(slack - left)}`
}
function CompactBanner({ cols, t }: { cols: number; t: Theme }) {
// -4 keeps a margin so exact-edge rows don't trip terminal pending-wrap.
const w = Math.max(28, cols - 4)
return (
<Box flexDirection="column" height={3} marginBottom={1} opaque width={w}>
<Text bold color={t.color.primary}>{ruleIn(t.brand.name, w)}</Text>
<Text color={t.color.muted}>{centerIn(TAG_FULL, w)}</Text>
<Text color={t.color.primary}>{'─'.repeat(w)}</Text>
</Box>
)
}
export function Banner({ maxWidth, t }: { maxWidth?: number; t: Theme }) {
const term = useStdout().stdout?.columns ?? 80
const cols = Math.max(1, Math.min(term, maxWidth ?? term))
if (cols < HIDE_BELOW) {
return null
}
const logoLines = logo(t.color, t.bannerLogo || undefined)
const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH
if (cols >= logoW + 2) {
return (
<Box flexDirection="column" marginBottom={1}>
<ArtLines lines={logoLines} />
<Text color={t.color.muted} wrap="truncate-end">
{t.brand.icon} {TAG_FULL}
</Text>
</Box>
)
}
if (cols >= COMPACT_FROM) {
return <CompactBanner cols={cols} t={t} />
}
const name = cols >= 52 ? t.brand.name : (t.brand.name.split(' ')[0] ?? t.brand.name)
const tag = cols >= 64 ? TAG_FULL : cols >= 46 ? TAG_MID : TAG_TINY
return (
<Box flexDirection="column" marginBottom={1}>
{cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? (
<ArtLines lines={logoLines} />
) : (
<Text bold color={t.color.primary}>
{t.brand.icon} NOUS HERMES
</Text>
)}
<Text color={t.color.muted}>{t.brand.icon} Nous Research · Messenger of the Digital Gods</Text>
<Text bold color={t.color.primary} wrap="truncate-end">{t.brand.icon} {name}</Text>
<Text color={t.color.muted} wrap="truncate-end">{t.brand.icon} {tag}</Text>
</Box>
)
}
@ -96,8 +157,9 @@ function CollapseToggle({
const SKILLS_MAX = 8
const TOOLSETS_MAX = 8
export function SessionPanel({ info, sid, t }: SessionPanelProps) {
const cols = useStdout().stdout?.columns ?? 100
export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
const term = useStdout().stdout?.columns ?? 100
const cols = Math.max(20, Math.min(term, maxWidth ?? term))
const heroLines = caduceus(t.color, t.bannerHero || undefined)
const leftW = Math.min((artWidth(heroLines) || CADUCEUS_WIDTH) + 4, Math.floor(cols * 0.4))
const wide = cols >= 90 && leftW + 40 < cols
@ -241,13 +303,33 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
)}
<Box flexDirection="column" width={w}>
<Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.primary}>
{t.brand.name}
{info.version ? ` v${info.version}` : ''}
{info.release_date ? ` (${info.release_date})` : ''}
</Text>
</Box>
{wide ? (
<Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.primary}>
{t.brand.name}
{info.version ? ` v${info.version}` : ''}
{info.release_date ? ` (${info.release_date})` : ''}
</Text>
</Box>
) : (
// Narrow layout hides the hero column; surface model/cwd/session
// here so they aren't lost.
<Box flexDirection="column" marginBottom={1}>
<Text color={t.color.accent} wrap="truncate-end">
{info.model.split('/').pop()}
<Text color={t.color.muted}> · Nous Research</Text>
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{info.cwd || process.cwd()}
</Text>
{sid && (
<Text wrap="truncate-end">
<Text color={t.color.sessionLabel}>Session: </Text>
<Text color={t.color.sessionBorder}>{sid}</Text>
</Text>
)}
</Box>
)}
{/* ── Tools (expanded by default) ── */}
<Box flexDirection="column" marginTop={1}>
@ -378,6 +460,7 @@ interface PanelProps {
interface SessionPanelProps {
info: SessionInfo
maxWidth?: number
sid?: string | null
t: Theme
}

View file

@ -51,6 +51,18 @@ const SLIDE_STEP = 12
const NOOP = () => {}
export const virtualHistorySnapshotKey = (s?: ScrollBoxHandle | null): string => {
if (!s) {
return 'none'
}
const target = s.getScrollTop() + s.getPendingDelta()
const bin = Math.floor(target / QUANTUM)
const viewportHeight = Math.max(0, s.getViewportHeight())
return `${s.isSticky() ? ~bin : bin}:${viewportHeight}`
}
const upperBound = (arr: ArrayLike<number>, target: number, length = arr.length) => {
let lo = 0
let hi = length
@ -174,11 +186,9 @@ export function useVirtualHistory(
}, [scrollRef])
// Quantized snapshot: same-bin scrolls (most wheel ticks) produce the same
// number → React.Object.is short-circuits the commit entirely. sticky state
// is folded in via the sign bit so sticky→broken transitions also trigger.
// Uses the TARGET (committed + pendingDelta), not committed scrollTop, so
// scrollBy notifications immediately remount for the destination before
// Ink's drain frames need the children.
// key → React.Object.is short-circuits the commit entirely. The key includes
// sticky state, target scroll position, and viewport height so resize-only
// changes still recompute the mounted transcript window.
const subscribe = useCallback(
(cb: () => void) => (hasScrollRef ? scrollRef.current?.subscribe(cb) : null) ?? NOOP,
[hasScrollRef, scrollRef]
@ -186,19 +196,8 @@ export function useVirtualHistory(
useSyncExternalStore(
subscribe,
() => {
const s = scrollRef.current
if (!s) {
return NaN
}
const target = s.getScrollTop() + s.getPendingDelta()
const bin = Math.floor(target / QUANTUM)
return s.isSticky() ? ~bin : bin
},
() => NaN
() => virtualHistorySnapshotKey(scrollRef.current),
() => 'none'
)
useEffect(() => {
@ -249,8 +248,26 @@ export function useVirtualHistory(
// During a freeze, drop the frozen range if items shrank past its start
// (/clear, compaction) — clamping would collapse to an empty mount and
// flash blank. Fall through to the normal path in that case.
const frozenRange =
freezeRenders.current > 0 && prevRange.current && prevRange.current[0] < n ? prevRange.current : null
const frozenRangeCandidate =
freezeRenders.current > 0 && prevRange.current && prevRange.current[0] < n
? ([prevRange.current[0], Math.min(prevRange.current[1], n)] as const)
: null
// Width grows can shrink wrapped rows enough that the old tail window no
// longer covers the viewport. In that case freezing preserves stale spacers
// and visually cuts off the last message, so recompute immediately.
const frozenRange = (() => {
if (!frozenRangeCandidate || vp <= 0) {
return frozenRangeCandidate
}
const visibleTop = sticky && !recentManual ? Math.max(0, total - vp) : target
const visibleBottom = visibleTop + vp
const rangeTop = offsets[frozenRangeCandidate[0]] ?? 0
const rangeBottom = offsets[frozenRangeCandidate[1]] ?? total
return rangeTop <= visibleTop && rangeBottom >= visibleBottom ? frozenRangeCandidate : null
})()
let start = 0
let end = n
@ -465,6 +482,7 @@ export function useVirtualHistory(
if (skipMeasurement.current) {
skipMeasurement.current = false
bumpMeasuredHeightVersion(n => n + 1)
} else {
for (let i = effStart; i < effEnd; i++) {
const k = items[i]?.key

View file

@ -61,6 +61,7 @@ function visualLines(value: string, cols: number): VisualLine[] {
}
lineStart = originalIdx
continue
}
@ -178,7 +179,8 @@ export function transcriptGutterWidth(role: Role, userPrompt: string) {
}
export function transcriptBodyWidth(totalCols: number, role: Role, userPrompt: string, termuxMode = false) {
const available = Math.max(1, totalCols - transcriptGutterWidth(role, userPrompt) - 2)
const horizontalReserve = termuxMode ? 2 : 4
const available = Math.max(1, totalCols - transcriptGutterWidth(role, userPrompt) - horizontalReserve)
if (termuxMode) {
// On narrow / unusual aspect-ratio mobile panes, forcing a wide minimum