diff --git a/ui-tui/packages/hermes-ink/src/ink/absolute-in-zero-height-box.test.tsx b/ui-tui/packages/hermes-ink/src/ink/absolute-in-zero-height-box.test.tsx new file mode 100644 index 0000000000..b8960e69a1 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/absolute-in-zero-height-box.test.tsx @@ -0,0 +1,77 @@ +import { EventEmitter } from 'events' + +import React from 'react' +import { describe, expect, it } from 'vitest' + +import Box from './components/Box.js' +import Text from './components/Text.js' +import Ink from './ink.js' + +class FakeTty extends EventEmitter { + chunks: string[] = [] + columns = 40 + rows = 8 + isTTY = true + + write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean { + this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + cb?.() + + return true + } +} + +const paint = (node: React.ReactElement) => { + const stdout = new FakeTty() + const stdin = new FakeTty() + const stderr = new FakeTty() + + const ink = new Ink({ + exitOnCtrlC: false, + patchConsole: false, + stderr: stderr as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + stdout: stdout as unknown as NodeJS.WriteStream + }) + + ink.render(node) + ink.onRender() + const frame = stdout.chunks.join('') + ink.unmount() + + return frame +} + +// The composer's floating panels (session switcher, model picker, …) are +// absolute `bottom: 100%` children of a relative Box whose only OTHER children +// — the input rows — unmount while an overlay is open. That leaves the host box +// at height 0 with a sibling on the same row, which is exactly the shape the +// same-row ghost guard skips. The guard must not take the escaping absolute +// child with it: it paints outside the host's bounds and can never ghost. +describe('absolute children of a zero-height box', () => { + it('paints an absolute bottom:100% panel when its host box collapses to h=0', () => { + const frame = paint( + + transcript + + + + PANEL-CONTENT + + + + footer + + ) + + expect(frame).toContain('PANEL-CONTENT') + }) + + // NOT covered here: that the guard still SUPPRESSES the same-row ghost it + // exists for (a squeezed box and its sibling both writing one row, leaving + // the longer content's tail behind). That path has no test upstream either, + // and the obvious candidates are vacuous — they pass with the guard deleted + // outright, which would silently reintroduce the ghost. Asserting it needs a + // tree where Yoga actually squeezes a node to h=0 onto a sibling's row (the + // HelpV2 shortcuts column is the known real case); worth adding separately. +}) 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 d1f6325fe9..9153afb36f 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 @@ -624,7 +624,14 @@ function renderNodeToOutput( // can give a box h=0 while still leaving a row for it (next sibling at // y+1, not y). HelpV2's third shortcuts column hits this — skipping // unconditionally drops "ctrl + z to suspend" from /help output. - if (height === 0 && siblingSharesY(node, yogaNode)) { + // + // So is the absolute-descendant check: a squeezed box still HOSTS + // overlays that escape its bounds (`bottom: 100%` floats above it), and + // those can't ghost the shared row because they never write it. The + // composer's relative Box hits this every time a floating panel opens — + // the input rows unmount, the host collapses to h=0, and skipping it + // would take the panel down with it (blank /resume, /model, /skills). + if (height === 0 && siblingSharesY(node, yogaNode) && !hasAbsoluteDescendant(node)) { nodeCache.set(node, { x, y, width, height, top: yogaTop }) node.dirty = false @@ -1684,6 +1691,27 @@ function siblingSharesY(node: DOMElement, yogaNode: LayoutNode): boolean { return false } +// Does this subtree contain a position:absolute node? Such a node paints +// outside its host's layout bounds, so the host's own rect being empty says +// nothing about whether the subtree has something to draw. Only consulted +// from the h=0 ghost guard — already a rare shape — so the walk never runs +// on the hot path. +function hasAbsoluteDescendant(node: DOMElement): boolean { + for (const child of node.childNodes) { + if (child.nodeName === '#text') { + continue + } + + const elem = child as DOMElement + + if (elem.style.position === 'absolute' || hasAbsoluteDescendant(elem)) { + return true + } + } + + return false +} + // When a node blits, its absolute-positioned descendants that paint outside // the node's layout bounds are NOT covered by the blit (which only copies // the node's own rect). If a dirty sibling re-rendered and overwrote those