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.
This commit is contained in:
Brooklyn Nicholson 2026-05-23 17:37:42 -05:00
parent 2a75bec607
commit 0277194e3b
6 changed files with 98 additions and 18 deletions

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
@ -896,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

@ -183,6 +183,35 @@ describe('useVirtualHistory offset cache reuse', () => {
}
})
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,8 +234,8 @@ export function useMainApp(gw: GatewayClient) {
}, [])
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(() => {
@ -424,10 +424,20 @@ export function useMainApp(gw: GatewayClient) {
let timer: ReturnType<typeof setTimeout> | undefined
// Resize reflows wrapped lines; if the user was 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 fires.
const onResize = () => {
const wasSticky = scrollRef.current?.isSticky() ?? false
clearTimeout(timer)
timer = setTimeout(() => {
timer = undefined
if (wasSticky) {
scrollRef.current?.scrollToBottom()
}
void rpc<TerminalResizeResponse>('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid })
}, 100)
}

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