diff --git a/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts b/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts index a11a028e771..c0935587d0f 100644 --- a/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/log-update.test.ts @@ -42,7 +42,8 @@ const stdoutOnly = (diff: ReturnType) => .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) diff --git a/ui-tui/packages/hermes-ink/src/ink/log-update.ts b/ui-tui/packages/hermes-ink/src/ink/log-update.ts index 0f36d4641e7..a428060b97d 100644 --- a/ui-tui/packages/hermes-ink/src/ink/log-update.ts +++ b/ui-tui/packages/hermes-ink/src/ink/log-update.ts @@ -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) 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 e85aad0f99f..5fee72cccaf 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 @@ -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 { diff --git a/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts b/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts index f946db99b75..a98b43972e6 100644 --- a/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts +++ b/ui-tui/src/__tests__/virtualHistoryOffsetCache.test.ts @@ -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' }, diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 71768bc2b0a..af12f50fab3 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -234,8 +234,8 @@ export function useMainApp(gw: GatewayClient) { }, []) const virtualRows = useMemo( - () => 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 | 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('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid }) }, 100) } diff --git a/ui-tui/src/lib/inputMetrics.ts b/ui-tui/src/lib/inputMetrics.ts index 860b7455a37..5311e8e888b 100644 --- a/ui-tui/src/lib/inputMetrics.ts +++ b/ui-tui/src/lib/inputMetrics.ts @@ -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