Inline rendering: autogrow and the ghosting trap¶
This doc is the engineering record for render_tree_inline_autogrow
(commit fc0a07c, reverting an earlier broken attempt at the same
idea). It's preserved because the failure mode — ghosting + duplicated
rows when content briefly overflows the terminal — is non-obvious from
reading either the old or the new code in isolation, and the
constraint that distinguishes the working version from the broken one
is one line.
Context¶
Inline mode (src/app/inline.cpp, the InlineSynced branch of
Runtime::render in src/app/app.cpp) renders into a canvas that
lives in terminal scrollback, not the alternate screen. The composer
(compose_inline_frame in src/render/serialize.cpp) diffs the new
canvas against the cached previous frame's cell buffer
(InlineFrameState::prev_cells) and emits relative cursor moves to
rewrite only the rows that changed.
Content height is data-driven. A streaming agent UI grows the canvas row by row as tokens arrive. The framework doesn't know up front how tall a frame will be — the layout engine computes it.
The legacy pattern (pre-autogrow)¶
canvas.clear();
render_tree(...); // build + compute + paint
int ch = content_height(canvas);
if (ch >= canvas.height() && !layout_nodes.empty()) {
int needed = layout_nodes[0].computed.size.height.raw();
if (needed > canvas.height()) {
canvas.resize(w, needed + 8);
canvas.clear();
render_tree(...); // build + compute + paint
ch = content_height(canvas);
}
}
The shape: paint first, measure after, resize if too small, paint
again. render_tree internally runs build_layout_tree →
layout::compute(w, canvas.height()) → paint_element. The first
pass uses canvas.height() as the height constraint; if the natural
height exceeds it, the second pass uses the resized height.
This pattern had two structural problems:
-
OOB cache window. The
ComponentElementcell-region cache (renderer.cpp, theComponentElementbranch ofpaint_element) captures painted cells back from the canvas after rendering a component. If the first paint pass overhung the canvas — because the canvas was about to be grown but hadn't been yet — the capture loop read pastcells_.size(), populating the cache with garbage StylePool IDs. The next frame's fast-path blit copied that garbage into the live canvas, andemit_cell_runcrashed on the bogus style ID. Commit65d946aband-aided this by clipping the capture read to canvas bounds, but the OOB window — a paint pass into a too-small canvas — was still there. -
Wasted paint on overflow. When content exceeded the initial 500-row canvas (rare but real for long agent responses), the framework did two complete paint passes — discarding the first.
The cleanest fix is to size the canvas before painting at all.
The first attempt (broken — reverted by e234066)¶
constexpr int kBigH = 1 << 20;
constexpr int kHSlack = 8;
// Build layout once with an "infinite" height constraint.
build_layout_tree(root, layout_nodes, theme);
layout_nodes[root_idx].style.width = Dimension::fixed(w);
layout::compute(layout_nodes, root_idx, w, kBigH);
const int natural_h = layout_nodes[root_idx].computed.size.height.raw();
// Resize if needed, then paint with that single layout result.
const int target = std::max(min_height, natural_h + kHSlack);
if (canvas.height() < target) canvas.resize(w, target);
paint_element(root, canvas, pool, layout_nodes, root_idx, 0, 0);
One compute, one paint. The canvas is sized correctly before paint, so the OOB window is closed. Both problems above are fixed.
It also caused visible ghosting and row duplication in interactive sessions.
What broke¶
The single line: layout::compute(..., kBigH).
The layout engine takes its height parameter as the available
height for the root container. That value flows down to children as
their containing block's main-axis size. Elastic children resolve
against it:
flex-grow > 0children in a column container distribute a share of the parent's remaining height. UnderkBigH, "remaining" is pathologically large.height: 100%/align-items: stretchcross-axis resolves against the parent's available height.- Min/max clamps fire under tight constraints that don't fire under loose ones, and vice versa.
Legacy passed canvas.height() (≈ 500 rows initially) as the
constraint. Elastic children distributed sensibly. The painted height
was close to the natural content height because the constraint
disciplined the distribution.
The new code passed kBigH. Elastic children claimed huge vertical
extents. The painted height routinely exceeded the terminal's visible
height — a 50-row painted frame on a 24-row terminal.
Why that ghosts¶
After paint, InlineFrameState::prev_rows records the painted height
(say 50). The terminal scrolled the older content out; only the
bottom 24 rows are visible. The next frame calls compose_inline_frame,
which positions the cursor relative to where it left off:
const int cursor_row_start = prev_rows - 1; // = 49
const int delta = first_changed - cursor_row_start; // e.g. -45
if (delta < 0) {
int up = std::min(-delta, prev_on_screen - 1); // capped at 23
if (up > 0) ansi::write_cursor_up(out, up);
out += '\r';
}
prev_on_screen = min(prev_rows, term_h) = 24. The prev_on_screen - 1
cap exists for a good reason: we must not scroll back into
scrollback-committed history. But it means when we need to move up
45 rows to reach first_changed, we move only 23. The cursor lands at
the wrong physical line.
The per-row emit loop then proceeds as if the cursor were at
first_changed:
for (int y = first_changed; y <= last_row_to_visit; ++y) {
if (y > first_changed) out += "\r\n";
// emit row y's diff
}
Each \r\n advances by one line. Because the cursor started 22 lines
below where the loop assumes, every row gets written 22 lines too
far down. Old content above stays visible; new content at the bottom
overwrites whatever was previously at those positions.
On the next frame when content shrinks back to a normal size, the cycle repeats with a different misalignment, producing the duplicated rows the user observes on the way back to a "normal" view.
The legacy code never tripped this because elastic flex distribution
under canvas.height()=500 produced painted heights close to the
natural content height — content rarely overflowed the terminal in a
single frame.
The fix¶
Keep compose_inline_frame untouched. Restore legacy's two-compute
pattern, but skip the wasted first paint:
// First compute — same constraint legacy passed; flex grow distributes
// under a realistic budget.
build_layout_tree(root, layout_nodes, theme);
layout_nodes[root_idx].style.width = Dimension::fixed(w);
layout::compute(layout_nodes, root_idx, w, canvas.height());
const int needed_h = layout_nodes[root_idx].computed.size.height.raw();
// Resize if too small, then RECOMPUTE under the new height constraint.
// The second compute is what produces the painted layout — flex
// children distribute identically to legacy's second compute.
const int target = std::max(min_height, needed_h + kHSlack);
if (canvas.height() < target) {
canvas.resize(w, target);
layout::compute(layout_nodes, root_idx, w, target);
}
// Single paint pass — into a correctly-sized canvas.
paint_element(root, canvas, pool, layout_nodes, root_idx, 0, 0);
Two changes from the broken attempt:
-
First compute at
canvas.height(), notkBigH. Flex distribution matches legacy. The layout engine still reports natural content height incomputed.size.heighteven when it exceeds the constraint, so we still discover the needed size. For frames that fit, this is the only compute. -
Recompute after resize. The painted layout is the result of the post-resize compute, with the new height as the constraint. That's exactly what legacy's second
render_treecall produced. Byte-for-byte visual equivalence with legacy.
What's preserved¶
| Property | Legacy | Broken attempt | Working version |
|---|---|---|---|
| Painted layout matches legacy | — | ✗ (kBigH skew) | ✓ |
| OOB cache window closed | ✗ | ✓ | ✓ |
| Paint passes per frame (no overflow) | 1 | 1 | 1 |
| Paint passes per frame (overflow) | 2 | 1 | 1 |
| Compute passes per frame (no overflow) | 1 | 1 | 1 |
| Compute passes per frame (overflow) | 2 | 1 | 2 |
The working version is at least as fast as legacy in every case, and strictly faster when content overflows (one paint saved). The OOB window is closed because we never paint into an undersized canvas.
Invariant for future readers¶
The layout that gets painted must come from a
layout::computecall whose height parameter is at least as large as the canvas about to receive the paint.
If you ever find yourself wanting to merge the two computes back into
one — by passing kBigH to the first compute, or by trusting that the
first compute's elastic distribution is the same as it would be under
a different constraint — re-read this doc. The two computes aren't
redundant.