mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-07 08:02:23 +00:00
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:
parent
2a75bec607
commit
0277194e3b
6 changed files with 98 additions and 18 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue