Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cli-scrollback-dup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix duplicated transcript content appearing in scrollback during streaming.
5 changes: 5 additions & 0 deletions .changeset/pi-tui-scrollback-dup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/pi-tui": patch
---

Pin the viewport anchor on partial shrinks and repaint above-viewport shifts in place, so streaming shrink/grow cycles no longer stack duplicate copies of content in scrollback; only a collapse past the viewport top re-anchors the view.
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm run test

# pi-tui's suite runs on node:test (not vitest), so the root `pnpm run test`
# does not execute it; it needs its own job.
test-pi-tui:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v6

- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: pnpm

- run: pnpm install --frozen-lockfile
- run: pnpm --filter @moonshot-ai/pi-tui test

test-windows:
runs-on: windows-latest
# Temporarily disabled while Windows tests are being stabilized.
Expand Down
3 changes: 2 additions & 1 deletion packages/pi-tui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ Never overwrite this directory wholesale when syncing from upstream. Each of the

## Testing

- This package's tests run with `node --test` (`pnpm --filter @moonshot-ai/pi-tui test`), not vitest; the root `vitest run` does not execute them.
- This package's tests run with `node --test` (`pnpm --filter @moonshot-ai/pi-tui test`), not vitest; the root `vitest run` does not execute them — CI covers them through the dedicated `test-pi-tui` job in `.github/workflows/ci.yml`. The `test` script runs both `test/*.test.ts` and `e2e/*.test.ts`; `test:e2e` runs the e2e cases alone.
- Prefer adding new narrow-width tests to the existing test file of the corresponding component.
- `e2e/` is a rendering-bug ledger: one case file per production rendering bug, driven end to end through the xterm-emulated `VirtualTerminal` (screen, scrollback, scroll position). When a rendering bug is found, add a `caseNN-*.test.ts` repro there and register it in `e2e/README.md`; keep it as a permanent regression guard after the fix. See `e2e/README.md` for the renderer invariants these cases assert.
71 changes: 71 additions & 0 deletions packages/pi-tui/e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# pi-tui rendering e2e cases

This folder is a **bug ledger**: one test file per rendering bug we hit in
production, driving the real `TUI` against an xterm-emulated terminal
(`test/virtual-terminal.ts`, backed by `@xterm/headless`). Each case
reproduces the original failure end to end — screen contents, scrollback,
scroll position — and stays as a permanent regression guard.

Run with:

```sh
pnpm --filter @moonshot-ai/pi-tui test:e2e # e2e cases only
pnpm --filter @moonshot-ai/pi-tui test # unit tests + e2e cases
```

## Background: the rendering model

`TUI.doRender()` renders differentially against a logical line buffer.
`previousViewportTop` anchors which logical line sits on the top screen row.
Terminal scrollback is **append-only**: rows enter it only by scrolling off
the top of the screen, and can never be partially rewritten or truncated.
That physical constraint drives three invariants the renderer must hold:

1. **Monotonic anchor** — `previousViewportTop` never rewinds (except on a
collapse, where the content ends at or above the screen top). Rewinding
repaints rows scrollback already holds; the next scroll commits them
again, duplicating the span.
2. **Exactly-once commit** — every row that crosses the viewport top must be
scrolled into scrollback exactly once: zero times loses the row, twice
duplicates it.
3. **Cursor bookkeeping sync** — the internal `hardwareCursorRow` must equal
the real terminal cursor row at all times; the differential path computes
relative moves from it, so any desync writes rows to the wrong place.

Accepted trade-offs (by design, not bugs):

- **Stale bytes**: content that changes above the viewport is not repainted
into scrollback; scrollback keeps the old version. This avoids `ESC[3J`,
which yanks the scroll position on Windows Terminal (microsoft/Terminal#20370).
- **Collapse seam**: a collapse rewind re-commits up to one screen of rows;
the content changed so drastically there that the seam is not recognizable.
- **Pinned gap**: a partial shrink keeps the anchor pinned, leaving a bounded
blank gap below the input box that the next growth refills.

## Case index

| Case | Symptom | Root cause | Status |
| --- | --- | --- | --- |
| [case01](./case01-collapse-blank-screen.test.ts) | Screen mostly blank after a large shrink (compaction), input box gone | Clamped differential path desynced the cursor when content collapsed above the viewport | Fixed (#1315) |
| [case02](./case02-collapse-scroll-position.test.ts) | Scroll position yanked to the top while reading scrollback (Windows Terminal) | Destructive full redraw emitted `ESC[3J` | Fixed (#1315) |
| [case03](./case03-full-redraw-residue.test.ts) | Stale text above the welcome banner after `/clear`; duplicated rows after ctrl+o expand | App-level session reset / expansion toggled content wholesale without a forced full redraw | Fixed (#1315, app layer uses `requestRender(true)`) |
| [case04](./case04-oscillation-duplication.test.ts) | Transcript spans duplicated in scrollback during streaming (shrink/grow oscillation) | Shrink re-anchor rewound the viewport anchor; the next grow re-committed rows scrollback already held | Fixed (#1353) |
| [case05](./case05-growth-past-anchor-row-loss.test.ts) | Transcript rows missing from scrollback after streaming | Growth past the anchor with an above-viewport change repainted in place without scrolling, so the skipped rows never got committed (invariant 2) | Fixed (#1353, review) |
| [case06](./case06-cursor-above-pinned-window.test.ts) | Rows written to the wrong screen position after a pinned shrink | `repaintViewport()` positioned the cursor to a logical row above the painted window; `hardwareCursorRow` desynced from the real cursor (invariant 3) | Fixed (#1353, review) |

A case for a not-yet-fixed bug is marked **Open** in the table and is
expected to fail — it is the executable reproduction. Flip it to Fixed
when it turns green.

Related unit-level guards (not duplicated here): kitty images straddling the
viewport top during a collapse are covered in `test/tui-shrink.test.ts`.

## Adding a case

1. Reproduce the bug with `createHarness()` from [harness.ts](./harness.ts):
drive frames with `frame(lines)`, then assert on `getViewport()`,
`getScrollBuffer()`, `getScrollPosition()`, or captured writes.
2. Name the file `caseNN-short-slug.test.ts` and add a row to the table
above: symptom as the user saw it, root cause, and which invariant broke.
3. Prefer unique per-line markers (`row-42`) so exactly-once assertions
cannot be confused by stale bytes.
59 changes: 59 additions & 0 deletions packages/pi-tui/e2e/case01-collapse-blank-screen.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { createHarness } from "./harness.ts";

// Case 01 — blank screen / missing input box after a large shrink.
//
// Symptom: after context compaction (or any event collapsing the transcript
// far above the viewport top), the screen went mostly blank and the input
// box disappeared; typing produced no visible feedback.
//
// Root cause: the differential path clamped the first changed line to the
// viewport but then rendered as if nothing had shifted, desyncing the
// cursor. Fixed in #1315 by re-anchoring the viewport to the tail of the
// collapsed content (repaintViewport).

describe("e2e case01: collapse must keep the input box on screen", () => {
it("shows the content tail and input box after a 60 -> 8 line collapse", async () => {
const h = await createHarness([...Array.from({ length: 59 }, (_, i) => `old-${i}`), "[INPUT-BOX]"], {
rows: 10,
});

// Collapse far above the viewport top (anchor is at 50) with a
// changed line above it — the original failure combination.
await h.frame(["new-0", "new-1", "new-2", "new-3", "new-4", "new-5", "new-6", "[INPUT-BOX]"]);

const viewport = h.terminal.getViewport();
assert.ok(
viewport.some((line) => line.includes("[INPUT-BOX]")),
`input box must stay visible, got: ${JSON.stringify(viewport)}`,
);
assert.ok(
viewport.some((line) => line.includes("new-0")),
"collapsed content must be visible",
);

// Self-healing: the next differential frame must land in place.
await h.frame(["new-0", "new-1", "new-2", "new-3", "new-4", "new-5", "new-6", "[INPUT-BOX]x"]);
assert.ok(
h.terminal.getViewport().some((line) => line.includes("[INPUT-BOX]x")),
"frame after the collapse must update the input box in place",
);

h.stop();
});

it("re-anchors to the tail when collapsed content is still taller than the screen", async () => {
const h = await createHarness([...Array.from({ length: 99 }, (_, i) => `old-${i}`), "[INPUT-BOX]"], {
rows: 10,
});

await h.frame([...Array.from({ length: 29 }, (_, i) => `new-${i}`), "[INPUT-BOX]"]);

const viewport = h.terminal.getViewport();
assert.ok(viewport[9]!.includes("[INPUT-BOX]"), `input box must sit on the bottom row, got: ${JSON.stringify(viewport)}`);
assert.ok(viewport[0]!.includes("new-20"), "viewport must show the tail of the new content");

h.stop();
});
});
53 changes: 53 additions & 0 deletions packages/pi-tui/e2e/case02-collapse-scroll-position.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { createHarness, type WriteCapturingTerminal } from "./harness.ts";

// Case 02 — scroll position yanked while reading scrollback.
//
// Symptom: on Windows Terminal, when the transcript collapsed while the
// user was scrolled up reading history, the view jumped to the absolute
// top of scrollback (microsoft/Terminal#20370: ESC[3J while scrolled
// into scrollback).
//
// Root cause: the fallback was a destructive full redraw that cleared
// scrollback with ESC[3J. Fixed in #1315: the collapse repaints only the
// live screen area, never emits ESC[3J, and leaves the scroll position
// alone.

describe("e2e case02: collapse must not yank the user's scroll position", () => {
it("preserves scroll position and never emits ESC[3J during a collapse", async () => {
const h = await createHarness([...Array.from({ length: 59 }, (_, i) => `old-${i}`), "[INPUT-BOX]"], {
rows: 10,
capture: true,
});
const terminal = h.terminal as WriteCapturingTerminal;

// User scrolls up into scrollback and starts reading.
terminal.scrollViewport(-20);
const scrolledPosition = terminal.getScrollPosition();
assert.ok(terminal.getViewport().some((line) => line.includes("old-35")));

terminal.writes = [];
await h.frame(["new-0", "new-1", "new-2", "new-3", "new-4", "new-5", "new-6", "[INPUT-BOX]"]);

assert.strictEqual(terminal.getScrollPosition(), scrolledPosition, "scroll position must be preserved");
assert.ok(
terminal.getViewport().some((line) => line.includes("old-35")),
"the user's scrolled view must still show the history",
);
assert.ok(
!terminal.writes.join("").includes("\x1b[3J"),
"a collapse must never clear scrollback with ESC[3J",
);

// Scrolling back down reveals the fresh content.
terminal.scrollToBottom();
const viewport = terminal.getViewport();
assert.ok(
viewport.some((line) => line.includes("[INPUT-BOX]")),
`input box must be at the bottom after scrolling down, got: ${JSON.stringify(viewport)}`,
);

h.stop();
});
});
70 changes: 70 additions & 0 deletions packages/pi-tui/e2e/case03-full-redraw-residue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { countInBuffer, createHarness } from "./harness.ts";

// Case 03 — stale text above the welcome banner; duplicated rows after
// expansion toggles.
//
// Symptom (a): after /new, /clear, or a session switch, the new session's
// welcome banner rendered with the previous session's text still visible
// above it.
// Symptom (b): pressing ctrl+o to expand collapsed tool output rendered a
// duplicated copy of earlier content above the transcript.
//
// Root cause: both flows replace the content wholesale, which the
// differential renderer cannot reconcile against an unrelated previous
// frame. Fixed in #1315 at the app layer: kimi-code calls
// `requestRender(true)` on session reset and on expansion toggles. This
// case guards the TUI primitive those fixes rely on: a forced render
// repaints the screen from scratch, leaving no residue and no duplicates
// in the visible viewport.

describe("e2e case03: forced full redraw leaves no residue on screen", () => {
it("clears previous-session text from the screen on a forced render (/clear)", async () => {
const h = await createHarness([...Array.from({ length: 59 }, (_, i) => `old-${i}`), "[INPUT-BOX]"], {
rows: 10,
});

// Session switch: unrelated short content + forced full redraw.
await h.frame(["[WELCOME]", "", "[INPUT-BOX]"], true);

const viewport = h.terminal.getViewport();
assert.ok(viewport.some((line) => line.includes("[WELCOME]")), "welcome banner must be visible");
assert.ok(viewport.some((line) => line.includes("[INPUT-BOX]")), "input box must be visible");
assert.ok(
!viewport.some((line) => line.includes("old-")),
`no previous-session text may remain on screen, got: ${JSON.stringify(viewport)}`,
);

h.stop();
});

it("renders expanded content exactly once on a forced render (ctrl+o)", async () => {
const collapsed = [
...Array.from({ length: 40 }, (_, i) => `line-${i}`),
"... (100 more lines, ctrl+o to expand)",
"[INPUT-BOX]",
];
const h = await createHarness(collapsed, { rows: 10 });

// Expansion regrows the transcript wholesale + forced full redraw.
const expanded = [
...Array.from({ length: 40 }, (_, i) => `line-${i}`),
...Array.from({ length: 20 }, (_, i) => `detail-${i}`),
"[INPUT-BOX]",
];
await h.frame(expanded, true);

const viewport = h.terminal.getViewport();
assert.ok(viewport[9]!.includes("[INPUT-BOX]"), "input box must sit on the bottom row");
for (const marker of ["detail-12", "detail-19"]) {
assert.strictEqual(
countInBuffer(viewport, marker),
1,
`"${marker}" must appear exactly once in the viewport`,
);
}

h.stop();
});
});
62 changes: 62 additions & 0 deletions packages/pi-tui/e2e/case04-oscillation-duplication.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { assertExactlyOnce, createHarness } from "./harness.ts";

// Case 04 — transcript spans duplicated in scrollback during streaming.
//
// Symptom: while an agent streamed output, scrolling up showed the same
// span of transcript (activity lines, messages) repeated over and over;
// each shrink/grow oscillation of the live region stacked another copy.
//
// Root cause: a partial shrink re-anchored (rewound) previousViewportTop,
// repainting rows scrollback already held; the next growth scrolled them
// out again. Fixed in #1353: the anchor never rewinds on a partial shrink
// (it stays pinned; growth refills the gap), so every row is committed to
// scrollback exactly once.

describe("e2e case04: shrink/grow oscillation must not duplicate scrollback", () => {
const transcript = (status: string, activity: string[]): string[] => [
...Array.from({ length: 30 }, (_, i) => `msg-${i}`),
status,
...Array.from({ length: 25 }, (_, i) => `out-${i}`),
...activity,
"[INPUT-BOX]",
];
const fullActivity = ["act-0", "act-1", "act-2", "act-3"];

it("keeps every row exactly once across repeated oscillations", async () => {
const h = await createHarness(transcript("status-0", fullActivity), { rows: 10 });

// Three oscillations: the activity pane collapses and regrows while
// a status line above the viewport keeps changing (streaming reflow).
for (let cycle = 1; cycle <= 3; cycle++) {
await h.frame(transcript(`status-${cycle}a`, []));
await h.frame(transcript(`status-${cycle}b`, fullActivity));
}

// Stable rows must appear exactly once — no duplicated spans, no
// losses. (Status lines are excluded: they legitimately leave stale
// copies above the viewport.)
assertExactlyOnce(h.terminal, ["msg-29", "out-0", "out-13", "out-24", "act-0", "act-3", "[INPUT-BOX]"]);

const viewport = h.terminal.getViewport();
assert.ok(viewport[9]!.includes("[INPUT-BOX]"), "input box must be back on the bottom row");

h.stop();
});

it("keeps the pinned gap bounded and refills it on growth", async () => {
const h = await createHarness(transcript("status-0", fullActivity), { rows: 10 });

await h.frame(transcript("status-0", []));
const pinned = h.terminal.getViewport();
const inputRow = pinned.findIndex((line) => line.includes("[INPUT-BOX]"));
assert.ok(inputRow >= 0 && inputRow < 9, "input box hovers above a bounded blank gap while pinned");
assert.strictEqual(pinned[9], "", "rows below the pinned content stay blank");

await h.frame(transcript("status-0", fullActivity));
assert.ok(h.terminal.getViewport()[9]!.includes("[INPUT-BOX]"), "growth refills the gap to the bottom row");

h.stop();
});
});
Loading
Loading