Skip to content

Transcript renders as blank lines until children or terminal *width* change — measured-line cache invalidated without re-triggering the measurement effect (WCr / ScrollBox) #4311

Description

@wuw92

Summary

In interactive mode the transcript blanks — most visibly the bottom region — while the content is still present (scrolling up reveals it). Nothing repaints it until I submit a new message. /resume does not recover it: the resumed session renders once, then blanks again.

Unlike #4222 / #4069 / #2802, there is no crash. Across 13 ~/.copilot/logs/process-*.log files (since 2026-07-24):

signature occurrences
Maximum update depth 0
React uncaught 0
write EIO 0
EPIPE 0

The Unregistering foreground session lines in the logs are ordinary session switches with no accompanying error. This is a distinct defect from the React render-loop family.

Root cause

Shipped 1.0.77 bundle, app.js offset ~6070985, function WCr — the measurement hook behind Kc / ScrollBox. Deminified and annotated:

function WCr({ children: t, renderWidth: e, terminalColors: n, colorMode: r, virtualized: o,
               viewportHeightRef: s, contentHeightRef: a, contentRef: l,
               clearSelectionRef: c, forceUpdate: d, setScrollOffset: u }) {
  let [g, p] = useState([]);   // g = renderedLines cache
  let [m, y] = useState([]);   // m = itemPrefixSums cache
  let S = useRef(null);        // last children
  let w = useRef(e);           // last renderWidth

  // (A) 100ms height poll — invalidates the caches when computed height changes
  useEffect(() => {
    if (o) return;
    let $ = setInterval(() => {
      let W = l.current?.yogaNode?.getComputedHeight();
      if (W !== undefined && W !== a.current) {
        a.current = W;
        p([]);                 // <-- clears renderedLines
        y([]);                 // <-- clears itemPrefixSums
        c.current();
        u(K => Math.min(K, Math.max(0, W - s.current)));
        d();
      }
    }, 100);
    return () => clearInterval($);
  }, [o, d, l, a, s, c, u]);

  // (B) the effect that REFILLS the caches
  useEffect(() => {
    if (o) return;
    let $ = e !== w.current;             // renderWidth changed?
    w.current = e;
    if (!$ && t === S.current) return;   // <-- GUARD: ignores n and r entirely
    S.current = t;
    let W = setTimeout(() => {
      let Y = [], K = [];
      React.Children.forEach(t, G => {
        K.push(Y.length);
        let z = wk(createElement(R, { flexDirection: "column" }, G), e, n ?? undefined, r);
        if (z) Y.push(...z.split("\n")); else Y.push("");
      });
      p(Y); y(K);
    }, 0);
    return () => clearTimeout(W);
  }, [t, e, n, r, o]);

  // (C) render-time fallback — an array of EMPTY STRINGS when the cache is empty
  let E = React.Children.count(t);
  let T = Math.max(E, Math.ceil(a.current));
  let x = useMemo(() => Array.from({ length: T }, () => ""), [T]);
  let B = g.length > 0 ? g : x;          // <-- blank lines
  ...
}

There are two separate defects here.

1. (A) clears the cache but changes none of (B)'s trigger conditions

p([]) / y([]) are setState calls on g / m, which are not in [t, e, n, r, o]. When the computed height changes while children stay identical — i.e. after a turn finishes and no new timeline entry arrives — (B) never re-runs, g stays [], and (C) renders T empty strings.

Nothing throws. The UI faithfully paints blank lines.

2. The guard in (B) ignores terminalColors and colorMode

n and r are in the dependency array, so a theme change does re-run the effect — but if (!$ && t === S.current) return; bails out before re-measuring, because neither width nor children changed. This is wrong independently of defect 1: the measurement wk(j, e, n, r) genuinely consumes the colors, so a theme change should invalidate the measured lines.

Confirmed empirically: switching themes while blank does not restore the transcript.

Only two escapes exist

renderWidth change or children change. And one layer up:

let { stdout: He } = Ts();
let Ee = He?.columns ?? 80;
let me = P.current || Ee;
// -> WCr({ children: t, renderWidth: me, ... })

renderWidth is derived from columns only, so resizing height alone does not help — the terminal width must change. Ctrl+L also does not recover it, since it changes neither width nor children.

Why each symptom follows

This is the non-virtualized branch (virtualized defaults to false in Kc). The main transcript uses that default; virtualized: true appears only in /tuikit demo screens and one internal list, so there is no user-facing way to avoid the affected path.

Reproduction

Intermittent, tied to height churn during streaming. Frequency drops sharply with --stream off, consistent with (A) being driven by per-delta height changes.

  1. copilot in an interactive terminal
  2. Submit a prompt that streams a long, height-varying reply (markdown tables / code fences)
  3. After the turn settles, the transcript blanks
  4. /resume renders once, then blanks again
  5. Type any new message — content reappears

Workarounds, and why they do or don't work

workaround result mechanism
resize terminal width works changes renderWidth (e) → passes the guard
type any message works changes children (t)
resize height only no effect renderWidth tracks columns only
Ctrl+L no effect changes neither width nor children
/theme toggle no effect defect 2 — guard ignores n / r
--stream off prevents removes per-delta height churn, so (A) rarely fires

Suggested fix

  1. Make (A) drive the refill — e.g. bump a measureNonce state in (A) and include it in (B)'s dependency array (or call the measurement routine directly instead of only clearing state).
  2. Include terminalColors / colorMode in (B)'s guard, not just its dependency array, so theme changes actually re-measure.
  3. Defensively, keep the previous g while re-measuring instead of falling back to empty strings, so a missed re-measure degrades to a stale frame rather than a blank one.

(3) alone would remove the user-visible failure even if the invalidation race persists.

Environment

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:terminal-renderingDisplay and rendering: flickering, scrolling, line wrapping, output formatting

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions