Cursor-agent TUI reprints the full conversation into scrollback while streaming (Ink concatenates Static + live region)

Ink is the React-for-terminals library bundled into cursor-agent. <Static> is Ink’s component for rows that should be written once (header, finished thoughts, tools). log-update is the in-place rewriter for the live tail (current paragraph, spinner, prompt). Scrollback is the terminal history above the viewport; \x1b[2J clears the viewport only, so each reprint leaves another copy of the header in history.

While a reply is streaming, the interactive TUI reprints the whole conversation into that scrollback on each paint.

This is not the model emitting duplicate tokens. A captured scrollback of one turn was 154 full UI frames (18,991 lines). The same sentence never appeared twice inside a single frame. Selecting all / copying the buffer looks like one conversation stacked 154 times. A visual copy had 0 ANSI left, which is why it looks like plain repeated text rather than an in-place rewrite.

Same class as these reports. This post names the concat that feeds the height check they already hit:

Repro

  1. Use a normal-height terminal (iTerm2 on macOS here; also reported on Ghostty and VS Code terminals).
  2. Run cursor-agent or agent in a repo.
  3. Send a prompt that streams a long reply and/or several thinking blocks, enough that frozen transcript plus chrome is taller than the window.
  4. While it streams, scroll the terminal buffer to the top.

No special content is required.

Finished rows should stay written once. Only the live tail should rewrite in place. Scrollback should have one header and one copy of each thought box.

What happens: every few paints the viewport is cleared and the whole conversation is printed again. Previous copies remain. The TUI is unusable to scroll or read during any long turn.

Mechanism

TTY onRender

onRender already receives a split: r / i are newly committed Static rows, t is the live region. Debug, CI, and screen-reader arms write r to stdout once. The normal TTY arm appends r into fullStaticOutput and sends that plus t through throttledLog:

// ../ink/build/ink.js onRender — TTY arm
i && (this.fullStaticOutput += r);
(i || t !== this.lastOutput) && this.throttledLog(this.fullStaticOutput + t);
this.lastOutput = t;
this.lastOutputHeight = n;

Every paint therefore asks log-update to rewrite the frozen transcript and the live tail together.

Thinking vs assistant text that share wording is a separate channel split (live thinkingContent overflow, then a Thought for box). That is not this bug.

Viewport-clear fallback

log-update.js create defaults incremental to false. run-agent.tsx does not pass incrementalRendering, so the non-incremental writer runs. If the number of lines to rewrite (h) is greater than process.stdout.rows, it writes a full-screen clear (ansi-escapes Ik = \x1b[2J) plus the entire frame and calls onFullClear:

// ../ink/build/log-update.js — non-incremental writer
if (h > process.stdout.rows) {
  b += r.Ik;          // viewport clear, not a scrollback wipe
  b += u.join("\n");
  o = 0;
  C = true;
}
// ...
if (C && n) n();      // onFullClear

onFullScreenClear in run-agent.tsx already logs that path:

ink full-screen clear (live region exceeds terminal height)
flowchart TD
    R["ink.js onRender"] --> OUT["renderer: t live, r Static"]
    OUT --> DBG{debug?}
    DBG -->|yes| DW["stdout.write(fullStaticOutput + t)"]
    DBG -->|no| CI{isInCi?}
    CI -->|yes| CIW["stdout.write(r); keep t"]
    CI -->|no| SR{screenReader?}
    SR -->|yes| SRW["erase live; write r; write t"]
    SR -->|no| TTY["fullStaticOutput += r; throttledLog(fullStaticOutput + t)"]
    TTY --> LU["log-update.js non-incremental"]
    LU --> HT{"h > stdout.rows?"}
    HT -->|yes| CLR["ESC 2J + full frame + onFullScreenClear"]
    HT -->|no| IP["erase prior lines; write frame"]

The TTY arm is the only interactive path that feeds frozen Static into log-update, so the height check compares the whole conversation to the window.

Stock Ink TTY path

Published Ink writes new Static rows to stdout once, then log-updates only output (ink.js:178):

// published Ink onRender TTY arm (not the Cursor fork)
if (hasStaticOutput) {
    this.fullStaticOutput += staticOutput;
}
if (this.lastOutputHeight >= this.options.stdout.rows) {
    this.options.stdout.write(ansiEscapes.clearTerminal + this.fullStaticOutput + output);
    this.lastOutput = output;
    this.lastOutputHeight = outputHeight;
    this.log.sync(output);
    return;
}
if (hasStaticOutput) {
    this.log.clear();
    this.options.stdout.write(staticOutput);
    this.log(output);
}
if (!hasStaticOutput && output !== this.lastOutput) {
    this.throttledLog(output);
}

That tree is a published Ink on disk, not the Cursor fork. It is the contrast for the TTY arm: pin staticOutput, rewrite output only.

Fix

In the fork’s TTY onRender:

  1. If there is new Static output, log.clear() the previous live region only if lastOutputHeight > 0, then stdout.write(r) to pin those rows.
  2. Log-update only t, not fullStaticOutput + t.
  3. Prefer the unthrottled this.log for that write. onRender is already FPS-throttled; a trailing throttledLog after clear() can rewrite a stale full frame.

A local 2026.08.25-3e8eec8 index.js patched that one unique expression and the stacked-header behavior stopped. Happy to share a minimal before/after of that expression, not the whole bundle.

Environment

Field Value
Product Cursor CLI (cursor-agent / agent interactive TUI)
OS macOS darwin 25, arm64
Terminal iTerm2, zsh
CLI 2026.08.25-3e8eec8 (cursor-agent about)
Model Not model-specific. Original capture streamed on Grok 4.6 High Fast
Blocks use? No. TUI unusable to scroll during a long turn

Hey @Mauricio_Andrades, what you’re seeing isn’t intended behavior, and it’s nothing on your end. This is an issue we’re tracking, and we’ve let the team know.

There’s no clean fix on the current CLI yet, but two things cut how often it triggers during a long turn: use a taller terminal window, and toggle compact mode with Ctrl+O. Both keep the live region under the window height.

I’ll follow up here when there’s an update.