#973·ink

<Static> commit taller than the viewport overwrites its own last line (incremental path ignores static-chunk height)

Author: MikeWang0316twCreated Jun 24, 2026Updated Jul 25, 2026

<Static> commit taller than the viewport overwrites its own last line (incremental path ignores static-chunk height)

Environment

  • ink: 7.0.3
  • node: v22.21.1
  • TTY terminal, rows = 36 (any terminal where a single committed <Static> item is taller than the viewport)

Summary

When a single <Static> item that is taller than the terminal viewport is committed in the same frame that a small dynamic (interactive) region is rendered below it, the last line of the static item is overwritten by the first line of the dynamic region. Visually the full item flashes for one frame and then its bottom line disappears.

The trigger is purely staticChunkHeight >= viewportRows. Static items shorter than the viewport commit correctly.

Root cause (from reading build/ink.js)

renderInteractiveFrame() decides between a safe full-repaint and an incremental write via shouldClearTerminalForFrame():

javascript
// shouldClearTerminalForFrame (≈L83)
const wasFullscreen   = previousOutputHeight >= viewportRows;
const wasOverflowing  = previousOutputHeight >  viewportRows;
const isOverflowing   = nextOutputHeight     >  viewportRows;
// ...decision uses ONLY previousOutputHeight / nextOutputHeight

previousOutputHeight / nextOutputHeight are the interactive (live) output heights only — the staticOutput chunk height is never considered.

So when a tall static item is committed while the live region is small (e.g. just an input box / footer), all of wasFullscreen, wasOverflowing, isOverflowing are false, shouldClearTerminal is false, and we take the incremental branch (≈L721):

javascript
if (hasStaticOutput) {
    this.log.clear();                 // erases previous live region (small)
    this.options.stdout.write(staticOutput);   // writes e.g. 40 lines into a 36-row TTY -> terminal SCROLLS
    this.log(outputToRender);         // writes the live region; log-update's cursor
                                      // assumptions are now off by the scrolled amount
}

Writing a static chunk taller than the viewport scrolls the terminal, which desynchronises log-update's relative cursor math, and the subsequent live-region write lands one line too high — on top of the static chunk's last line.

Note: even the "safe" path wouldn't be ideal here — ansiEscapes.clearTerminal is \x1b[2J\x1b[3J\x1b[H, i.e. it wipes the scrollback buffer. So routing tall static commits through the existing full-clear fallback would destroy scrollback history; the incremental path needs to handle the tall-chunk case correctly instead.

Minimal reproduction

javascript
import React, {useEffect, useState} from 'react';
import {render, Static, Box, Text} from 'ink';

// Run in a real TTY whose height is LESS than 50 rows.
const App = () => {
  const [items, setItems] = useState([]);
  useEffect(() => {
    const t = setTimeout(() => {
      // one Static item taller than the viewport, committed in a single frame
      setItems([Array.from({length: 50}, (_, i) => `line ${i + 1}`).join('\n')]);
    }, 600);
    return () => clearTimeout(t);
  }, []);
  return (
    <>
      <Static items={items}>
        {(item, i) => <Text key={i}>{item}</Text>}
      </Static>
      {/* small live region present before AND after the commit */}
      <Box borderStyle="round" paddingX={1}>
        <Text>input box</Text>
      </Box>
    </>
  );
};

render(<App />);

Expected

All 50 lines scroll into the scrollback buffer; line 50 is visible directly above the input box border.

Actual

line 50 flashes for one frame, then the top border of input box overwrites it — line 49 ends up directly above the box and line 50 is lost from the visible screen.

(The truncation was first observed and confirmed via per-render instrumentation in a downstream app — see "How this was found" below. The snippet above isolates the same code path; it needs a real TTY shorter than the static item to exhibit the overwrite, since non-TTY output skips the interactive cursor management.)

Suggested fix

Account for the committed staticOutput height when choosing the render path, e.g. pass the static-chunk line count into shouldClearTerminalForFrame() (or guard the incremental branch) so that a static chunk >= viewportRows is handled without the scroll/cursor desync — and ideally without falling back to the scrollback-wiping clearTerminal.

How this was found

Observed downstream in qwen-code (an Ink-based CLI): assistant messages longer than the terminal height lose their last line on completion. Per-render instrumentation confirmed the footer/live-region height was measured correctly and stably the whole time (live height 6, available 25, viewport 36); the truncation correlated exactly with the completed message height crossing the viewport height (23-line message fine, 40-line message truncated), which led to the Ink static-commit path above.