fix(ui-tui/ink): don't skip a zero-height box that hosts absolute children
Opening any floating panel in the TUI (`/resume`, `/sessions`, `/models`, `/skills`, …) with an ambient dock widget loaded shows nothing: the overlay takes input (Esc is the only way out) but never paints. Part of #69592. `renderNodeToOutput` has a ghost guard for boxes Yoga squeezes to h=0 whose sibling lands on the same row — without it, the shorter content leaves the longer one's tail on screen. The guard returns before rendering children. The composer's floating panels are `position: absolute; bottom: 100%` children of a relative Box that also holds the input rows. Opening a panel sets `$isBlocked`, which unmounts those rows, so the host collapses to h=0 with a sibling on its row — and the guard drops the whole subtree, overlay included. An absolute child paints outside its host's layout bounds, so it never writes the shared row and cannot ghost it. Skip only when the subtree has no absolute descendant. The walk runs solely inside the h=0 branch, which is already rare, so it stays off the hot path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
<Box flexDirection="column">
|
||||
<Text>transcript</Text>
|
||||
|
||||
<Box flexDirection="column" position="relative">
|
||||
<Box bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
|
||||
<Text>PANEL-CONTENT</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Text>footer</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
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.
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user