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:
@@ -42,7 +42,8 @@ const stdoutOnly = (diff: ReturnType<LogUpdate['render']>) =>
|
|||||||
.map(p => (p as { type: 'stdout'; content: string }).content)
|
.map(p => (p as { type: 'stdout'; content: string }).content)
|
||||||
.join('')
|
.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', () => {
|
describe('LogUpdate.render diff contract', () => {
|
||||||
it('emits only changed cells when most rows match', () => {
|
it('emits only changed cells when most rows match', () => {
|
||||||
@@ -87,6 +88,25 @@ describe('LogUpdate.render diff contract', () => {
|
|||||||
expect(stdoutOnly(diff)).toContain('shorterrownow')
|
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', () => {
|
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
|
// Load-bearing theory for the rapid-resize scattered-letter bug: if the
|
||||||
// physical terminal has stale cells that prev.screen doesn't know about
|
// 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')
|
paint(next, 1, 'row one')
|
||||||
|
|
||||||
const prevFrame = mkFrame(prev, w, h)
|
const prevFrame = mkFrame(prev, w, h)
|
||||||
|
|
||||||
const nextFrame: Frame = {
|
const nextFrame: Frame = {
|
||||||
...mkFrame(next, w, h),
|
...mkFrame(next, w, h),
|
||||||
scrollHint: { top: 1, bottom: 4, delta: 1 }
|
scrollHint: { top: 1, bottom: 4, delta: 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
const log = new LogUpdate({ isTTY: true, stylePool })
|
const log = new LogUpdate({ isTTY: true, stylePool })
|
||||||
const diff = log.render(prevFrame, nextFrame, true, true)
|
const diff = log.render(prevFrame, nextFrame, true, true)
|
||||||
|
|
||||||
@@ -187,10 +209,12 @@ describe('LogUpdate.render diff contract', () => {
|
|||||||
paint(next, 1, 'row one')
|
paint(next, 1, 'row one')
|
||||||
|
|
||||||
const prevFrame = mkFrame(prev, w, h)
|
const prevFrame = mkFrame(prev, w, h)
|
||||||
|
|
||||||
const nextFrame: Frame = {
|
const nextFrame: Frame = {
|
||||||
...mkFrame(next, w, h),
|
...mkFrame(next, w, h),
|
||||||
scrollHint: { top: 1, bottom: 5, delta: 1 }
|
scrollHint: { top: 1, bottom: 5, delta: 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
const log = new LogUpdate({ isTTY: true, stylePool })
|
const log = new LogUpdate({ isTTY: true, stylePool })
|
||||||
const diff = log.render(prevFrame, nextFrame, true, true)
|
const diff = log.render(prevFrame, nextFrame, true, true)
|
||||||
|
|
||||||
|
|||||||
@@ -141,14 +141,12 @@ export class LogUpdate {
|
|||||||
const startTime = performance.now()
|
const startTime = performance.now()
|
||||||
const stylePool = this.options.stylePool
|
const stylePool = this.options.stylePool
|
||||||
|
|
||||||
// Since we assume the cursor is at the bottom on the screen, we only need
|
// Terminal hosts can reflow/preserve old cells on any resize, including
|
||||||
// to clear when the viewport gets shorter (i.e. the cursor position drifts)
|
// height-only growth. A partial diff can then leave stale transcript rows
|
||||||
// or when it gets thinner (and text wraps). We _could_ figure out how to
|
// or cut off bordered content even when our virtual scrollTop is correct.
|
||||||
// not reset here but that would involve predicting the current layout
|
// Resizing is rare enough that a full repaint is the safer tradeoff.
|
||||||
// _after_ the viewport change which means calcuating text wrapping.
|
|
||||||
// Resizing is a rare enough event that it's not practically a big issue.
|
|
||||||
if (
|
if (
|
||||||
next.viewport.height < prev.viewport.height ||
|
next.viewport.height !== prev.viewport.height ||
|
||||||
(prev.viewport.width !== 0 && next.viewport.width !== prev.viewport.width)
|
(prev.viewport.width !== 0 && next.viewport.width !== prev.viewport.width)
|
||||||
) {
|
) {
|
||||||
return fullResetSequence_CAUSES_FLICKER(next, 'resize', stylePool)
|
return fullResetSequence_CAUSES_FLICKER(next, 'resize', stylePool)
|
||||||
|
|||||||
@@ -706,12 +706,22 @@ function renderNodeToOutput(
|
|||||||
const content = node.childNodes.find(c => (c as DOMElement).yogaNode) as DOMElement | undefined
|
const content = node.childNodes.find(c => (c as DOMElement).yogaNode) as DOMElement | undefined
|
||||||
|
|
||||||
const contentYoga = content?.yogaNode
|
const contentYoga = content?.yogaNode
|
||||||
// scrollHeight is the intrinsic height of the content wrapper.
|
// scrollHeight is the intrinsic height of the content wrapper, but
|
||||||
// Do NOT add getComputedTop() — that's the wrapper's offset
|
// after terminal resizes Yoga can leave tall descendants overflowing
|
||||||
// within the viewport (equal to the scroll container's
|
// that wrapper. Use the deepest direct child bottom so sticky-bottom
|
||||||
// paddingTop), and innerHeight already subtracts padding, so
|
// math can still reach the real final rendered row.
|
||||||
// including it double-counts padding and inflates maxScroll.
|
let scrollHeight = Math.ceil(contentYoga?.getComputedHeight() ?? 0)
|
||||||
const scrollHeight = 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
|
// Capture previous scroll bounds BEFORE overwriting — the at-bottom
|
||||||
// follow check compares against last frame's max.
|
// follow check compares against last frame's max.
|
||||||
const prevScrollHeight = node.scrollHeight ?? scrollHeight
|
const prevScrollHeight = node.scrollHeight ?? scrollHeight
|
||||||
@@ -896,7 +906,14 @@ function renderNodeToOutput(
|
|||||||
const regionTop = Math.floor(y + contentYoga.getComputedTop())
|
const regionTop = Math.floor(y + contentYoga.getComputedTop())
|
||||||
const regionBottom = regionTop + innerHeight - 1
|
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 }
|
hint = { top: regionTop, bottom: regionBottom, delta }
|
||||||
scrollHint = hint
|
scrollHint = hint
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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 () => {
|
it('recomputes offsets after a mounted row height changes', async () => {
|
||||||
const tall = [
|
const tall = [
|
||||||
{ height: 6, key: 'a' },
|
{ height: 6, key: 'a' },
|
||||||
|
|||||||
@@ -234,8 +234,8 @@ export function useMainApp(gw: GatewayClient) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const virtualRows = useMemo<TranscriptRow[]>(
|
const virtualRows = useMemo<TranscriptRow[]>(
|
||||||
() => historyItems.map((msg, index) => ({ index, key: messageId(msg), msg })),
|
() => historyItems.map((msg, index) => ({ index, key: `${messageId(msg)}:c${cols}`, msg })),
|
||||||
[historyItems, messageId]
|
[cols, historyItems, messageId]
|
||||||
)
|
)
|
||||||
|
|
||||||
const detailsLayoutKey = useMemo(() => {
|
const detailsLayoutKey = useMemo(() => {
|
||||||
@@ -424,10 +424,20 @@ export function useMainApp(gw: GatewayClient) {
|
|||||||
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
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 onResize = () => {
|
||||||
|
const wasSticky = scrollRef.current?.isSticky() ?? false
|
||||||
|
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
timer = undefined
|
timer = undefined
|
||||||
|
|
||||||
|
if (wasSticky) {
|
||||||
|
scrollRef.current?.scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
void rpc<TerminalResizeResponse>('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid })
|
void rpc<TerminalResizeResponse>('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid })
|
||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ function visualLines(value: string, cols: number): VisualLine[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lineStart = originalIdx
|
lineStart = originalIdx
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +179,8 @@ export function transcriptGutterWidth(role: Role, userPrompt: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function transcriptBodyWidth(totalCols: number, role: Role, userPrompt: string, termuxMode = false) {
|
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) {
|
if (termuxMode) {
|
||||||
// On narrow / unusual aspect-ratio mobile panes, forcing a wide minimum
|
// On narrow / unusual aspect-ratio mobile panes, forcing a wide minimum
|
||||||
|
|||||||
Reference in New Issue
Block a user