mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
revert(pi-tui): restore upstream differential rendering behavior (#1367)
* fix(pi-tui): make the viewport anchor follow above-viewport content shifts The anchor pins a buffer row index, but an above-viewport length change shifts the content living at every index below it. The pinned window then suddenly showed content further along (visible upward creep), and the rows that slid above the window top were lost: never committed to scrollback, which holds older bytes at those indices. During streaming, every above-viewport net shrink (a finished agent row collapsing, a merged step, a spinner line disappearing) permanently swallowed that many rows, and the blank area under the input box kept growing. doRender now scores two hypotheses for such frames — window stayed put vs window content shifted by the length delta — and when the shift explains the frame strictly better, moves the anchor with the content: a pure shift re-anchors with no painting at all (the screen already shows exactly that content), and a shift with local in-window changes (spinner/timer rows) repaints the window at the shifted anchor. Commit order stays continuous, so the exactly-once scrollback invariant is preserved: no loss, no duplication. Covered by e2e case07 (above-viewport shift), which fails on the previous revision; the stale-content unit test now asserts the follow-the-content window instead of the old swallowed-row behavior. * revert(pi-tui): restore upstream differential rendering behavior The fork's viewport/scrollback rendering patches (clamping the diff to the visible viewport, viewport re-anchoring on collapse, the pinned anchor with commit-on-advance, cursor visibility guarding, and the content-shift anchor follow) accumulated interacting edge cases faster than they could be stabilized: blank screens, duplicated scrollback spans, vanished rows, and a growing blank area under the input box. Revert src/tui.ts to the upstream 0.80.2 differential rendering behavior: a change above the viewport triggers a destructive full redraw again. Verified line-by-line against the upstream source — the only remaining divergences are the TypeScript strict-mode syntax adaptations and the narrow-terminal fixes (Container width clamping and overwide-line truncation replacing the upstream crash-and-throw), which are kept. Also remove the rendering-bug e2e ledger and the shrink test suite that specified the reverted behavior, restore the pre-fork rendering tests (the transient-content test asserts the upstream full-redraw behavior again), and drop the e2e glob from the test script. Editor input-history and paste-burst changes are untouched. Known trade-off, accepted for now: the original scroll-position yank during streaming (destructive redraws emitting ESC[3J) returns; the rendering rework will restart from this clean baseline. * chore: downgrade the web thinking-effort changeset to patch
This commit is contained in:
parent
26b90225d2
commit
23daf0f3c1
17 changed files with 44 additions and 1104 deletions
5
.changeset/cli-revert-rendering.md
Normal file
5
.changeset/cli-revert-rendering.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Revert the recent TUI transcript rendering changes to the original upstream behavior while rendering issues are reworked.
|
||||
5
.changeset/pi-tui-revert-rendering.md
Normal file
5
.changeset/pi-tui-revert-rendering.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/pi-tui": patch
|
||||
---
|
||||
|
||||
Revert the fork's viewport and scrollback rendering patches, restoring the upstream differential-rendering behavior. The narrow-terminal fixes (width clamping, overwide-line truncation) are kept.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": minor
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Add a segmented thinking-level control in the web model picker for models that support multiple reasoning efforts. Open the composer model menu to choose a level.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# pi-tui Agent Guide
|
||||
|
||||
`packages/pi-tui` is a vendored copy of pi-tui from the upstream pi-mono project (baseline: upstream 0.80.2, see commit `7859b0af`). It is no longer patched via pnpm patches — all local fixes are applied directly to the source.
|
||||
`packages/pi-tui` is a vendored copy of pi-tui from the upstream pi-mono project (baseline: upstream 0.80.2, see commit `7859b0af`). It is no longer patched via pnpm patches — all local fixes are applied directly to the source. The differential-rendering behavior in `src/tui.ts` matches upstream: the fork's viewport/scrollback rendering patches were reverted; the only remaining divergences are listed below.
|
||||
|
||||
## Local divergences from upstream (must be preserved on every re-vendor)
|
||||
|
||||
|
|
@ -17,6 +17,5 @@ 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 — 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.
|
||||
- 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`.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { createHarness } from "./harness.ts";
|
||||
|
||||
// Case 05 — transcript rows missing from scrollback after streaming.
|
||||
//
|
||||
// Symptom: after long streaming turns, scrolling up showed gaps: spans of
|
||||
// transcript rows were simply absent from scrollback.
|
||||
//
|
||||
// Root cause (invariant 2, exactly-once commit): when a single frame both
|
||||
// changed a line above the viewport (status/reflow) and grew the content
|
||||
// past the pinned anchor, the length-change branch advanced the anchor and
|
||||
// repainted the screen in place without scrolling. The rows between the
|
||||
// old and new anchor were overwritten on screen and never committed to
|
||||
// scrollback. Found by review on #1353.
|
||||
//
|
||||
// Fixed on #1353: an anchor advance now paints from the old anchor and
|
||||
// scrolls the skipped rows into scrollback, committing each exactly once.
|
||||
|
||||
describe("e2e case05: growth past the anchor must not lose rows", () => {
|
||||
it("commits the rows skipped by an anchor advance exactly once", async () => {
|
||||
// 60 lines at height 10 -> anchor 50; rows 50..59 are on screen and
|
||||
// not yet committed to scrollback.
|
||||
const h = await createHarness([...Array.from({ length: 59 }, (_, i) => `row-${i}`), "[INPUT-BOX]"], {
|
||||
rows: 10,
|
||||
});
|
||||
|
||||
// One frame combines an above-viewport change (row-10 completes)
|
||||
// with growth past the anchor (3 appended rows): anchor 50 -> 53.
|
||||
const grown = [
|
||||
...Array.from({ length: 59 }, (_, i) => (i === 10 ? "row-10 [done]" : `row-${i}`)),
|
||||
"tail-0",
|
||||
"tail-1",
|
||||
"tail-2",
|
||||
"[INPUT-BOX]",
|
||||
];
|
||||
await h.frame(grown);
|
||||
|
||||
// The rows the anchor skipped over must be in the buffer exactly
|
||||
// once — they crossed the viewport top and have to be committed.
|
||||
const buffer = h.terminal.getScrollBuffer();
|
||||
for (const marker of ["row-50", "row-51", "row-52"]) {
|
||||
const count = buffer.filter((line) => line.includes(marker)).length;
|
||||
assert.strictEqual(count, 1, `"${marker}" must survive the anchor advance, got ${count} occurrences`);
|
||||
}
|
||||
|
||||
// And the fresh tail is visible with the input box at the bottom.
|
||||
const viewport = h.terminal.getViewport();
|
||||
assert.ok(viewport.some((line) => line.includes("tail-2")), "appended rows must be visible");
|
||||
assert.ok(viewport[9]!.includes("[INPUT-BOX]"), "input box must sit on the bottom row");
|
||||
|
||||
h.stop();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { CURSOR_MARKER } from "../src/tui.ts";
|
||||
import { countInBuffer, createHarness } from "./harness.ts";
|
||||
|
||||
// Case 06 — rows written to the wrong screen position after a pinned shrink.
|
||||
//
|
||||
// Symptom: after the transcript shrank while the viewport stayed pinned,
|
||||
// subsequent updates landed on the wrong screen rows: lines duplicated or
|
||||
// misplaced a couple of rows below where they belonged.
|
||||
//
|
||||
// Root cause (invariant 3, cursor bookkeeping sync): extractCursorPosition
|
||||
// scans the bottom `height` rows of the content, but a pinned repaint
|
||||
// paints `pinnedTop..pinnedTop+height-1` — a higher window. A cursor
|
||||
// marker in the gap between the two (e.g. a tall input editor poking above
|
||||
// the pinned window) made positionHardwareCursor record hardwareCursorRow
|
||||
// on an invisible logical row while the real cursor clamped at the screen
|
||||
// top. The next differential frame computed its relative move from the
|
||||
// desynced value and wrote to the wrong rows. Found by review on #1353.
|
||||
//
|
||||
// Fixed on #1353: the cursor is only positioned when the marker row is
|
||||
// inside the visible window; otherwise it is hidden and the bookkeeping
|
||||
// stays on the real cursor row.
|
||||
|
||||
describe("e2e case06: pinned repaint must keep cursor bookkeeping in sync", () => {
|
||||
it("places the next differential update on the correct screen row", async () => {
|
||||
// 60 lines at height 10 -> anchor 50; cursor marker at the bottom.
|
||||
const h = await createHarness(
|
||||
[...Array.from({ length: 59 }, (_, i) => `old-${i}`), `[INPUT]${CURSOR_MARKER}`],
|
||||
{ rows: 10 },
|
||||
);
|
||||
|
||||
// Shrink 60 -> 56 with everything shifted (change above the
|
||||
// viewport): the viewport stays pinned at 50, painting rows 50..55.
|
||||
// The cursor marker sits at row 48 — inside the bottom-10 scan
|
||||
// window (46..55) but above the painted window.
|
||||
const pinned = Array.from({ length: 56 }, (_, i) => (i === 48 ? `new-48${CURSOR_MARKER}` : `new-${i}`));
|
||||
await h.frame(pinned);
|
||||
|
||||
// Next frame changes row 55 (screen row 5 of the pinned window).
|
||||
const next = pinned.map((line, i) => (i === 55 ? "new-55 CHANGED" : line));
|
||||
await h.frame(next);
|
||||
|
||||
const viewport = h.terminal.getViewport();
|
||||
assert.ok(
|
||||
viewport[5]!.includes("new-55 CHANGED"),
|
||||
`row 55 must render on screen row 5, got: ${JSON.stringify(viewport)}`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
countInBuffer(viewport, "new-55"),
|
||||
1,
|
||||
"the changed row must not be duplicated onto another screen row",
|
||||
);
|
||||
|
||||
h.stop();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
import assert from "node:assert";
|
||||
import { type Component, TUI } from "../src/tui.ts";
|
||||
import { VirtualTerminal } from "../test/virtual-terminal.ts";
|
||||
|
||||
/** A component whose rendered lines are fully controlled by the test. */
|
||||
export class Lines implements Component {
|
||||
private lines: string[] = [];
|
||||
|
||||
setLines(lines: string[]): void {
|
||||
this.lines = lines;
|
||||
}
|
||||
|
||||
render(): string[] {
|
||||
return this.lines;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
/** VirtualTerminal that also records every raw write for escape-sequence assertions. */
|
||||
export class WriteCapturingTerminal extends VirtualTerminal {
|
||||
writes: string[] = [];
|
||||
|
||||
override write(data: string): void {
|
||||
this.writes.push(data);
|
||||
super.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
export interface Harness<T extends VirtualTerminal = VirtualTerminal> {
|
||||
terminal: T;
|
||||
tui: TUI;
|
||||
content: Lines;
|
||||
/** Replace the content lines and wait for the next render to settle. */
|
||||
frame(lines: string[], force?: boolean): Promise<void>;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/** Build a TUI driven by a single Lines component on a virtual terminal. */
|
||||
export async function createHarness(
|
||||
initialLines: string[],
|
||||
options?: { columns?: number; rows?: number; capture?: boolean },
|
||||
): Promise<Harness<VirtualTerminal | WriteCapturingTerminal>> {
|
||||
const columns = options?.columns ?? 60;
|
||||
const rows = options?.rows ?? 10;
|
||||
const terminal = options?.capture ? new WriteCapturingTerminal(columns, rows) : new VirtualTerminal(columns, rows);
|
||||
const tui = new TUI(terminal);
|
||||
const content = new Lines();
|
||||
tui.addChild(content);
|
||||
content.setLines(initialLines);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
return {
|
||||
terminal,
|
||||
tui,
|
||||
content,
|
||||
async frame(lines: string[], force = false): Promise<void> {
|
||||
content.setLines(lines);
|
||||
tui.requestRender(force);
|
||||
await terminal.waitForRender();
|
||||
},
|
||||
stop(): void {
|
||||
tui.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Count buffer lines (scrollback + screen) containing the marker. */
|
||||
export function countInBuffer(buffer: string[], marker: string): number {
|
||||
return buffer.filter((line) => line.includes(marker)).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert each marker appears exactly once across scrollback + screen.
|
||||
* Catches both duplication (count > 1) and row loss (count === 0).
|
||||
* Only use markers whose logical line did not change between frames:
|
||||
* lines rewritten above the viewport legitimately leave a stale copy
|
||||
* in scrollback (see e2e/README.md, "stale bytes" trade-off).
|
||||
*/
|
||||
export function assertExactlyOnce(terminal: VirtualTerminal, markers: string[]): void {
|
||||
const buffer = terminal.getScrollBuffer();
|
||||
for (const marker of markers) {
|
||||
const count = countInBuffer(buffer, marker);
|
||||
assert.strictEqual(count, 1, `"${marker}" should appear exactly once in the buffer, got ${count}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -56,8 +56,7 @@
|
|||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "node --test test/*.test.ts e2e/*.test.ts",
|
||||
"test:e2e": "node --test e2e/*.test.ts",
|
||||
"test": "node --test test/*.test.ts",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1341,7 +1341,7 @@ export class TUI extends Container {
|
|||
}
|
||||
const bufferLength = Math.max(height, newLines.length);
|
||||
this.previousViewportTop = Math.max(0, bufferLength - height);
|
||||
this.positionHardwareCursor(cursorPos, newLines.length, this.previousViewportTop, height);
|
||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||
this.previousLines = newLines;
|
||||
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
||||
this.previousWidth = width;
|
||||
|
|
@ -1388,104 +1388,6 @@ export class TUI extends Container {
|
|||
return;
|
||||
}
|
||||
|
||||
// Repaint the visible viewport in place with the tail of the new
|
||||
// content, re-anchoring previousViewportTop to desiredTop. Used when
|
||||
// the differential path cannot run safely or would push duplicate
|
||||
// rows into scrollback. Emits no ESC[3J. When desiredTop advances
|
||||
// past the current anchor, the skipped rows are painted with fresh
|
||||
// content and scrolled into scrollback (committed exactly once);
|
||||
// otherwise nothing scrolls, so scrollback and the user's scroll
|
||||
// position stay intact.
|
||||
const repaintViewport = (desiredTop: number, reason: string): void => {
|
||||
// An advancing anchor must commit the rows it skips: paint from
|
||||
// the old anchor and let the paint loop scroll `advance` rows out
|
||||
// of the screen top. A pinned or rewound anchor paints the target
|
||||
// window in place without scrolling.
|
||||
const advance = Math.max(0, desiredTop - prevViewportTop);
|
||||
const paintStart = advance > 0 ? prevViewportTop : desiredTop;
|
||||
const paintCount = height + advance;
|
||||
// Kitty image lines need multi-row placement; fall back to a
|
||||
// destructive full render for that rare combination.
|
||||
for (let r = 0; r < paintCount; r++) {
|
||||
const idx = paintStart + r;
|
||||
if (idx < newLines.length && isImageLine(newLines[idx]!)) {
|
||||
logRedraw(`viewport repaint hit kitty image at line ${idx}`);
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
logRedraw(`viewport repaint: ${reason}`);
|
||||
let repaint = "\x1b[?2026h"; // Begin synchronized output
|
||||
// Delete kitty images placed in the old visible viewport; images
|
||||
// above it live in scrollback and are left alone. A multi-row
|
||||
// image can straddle the viewport top: its id lives on the image
|
||||
// line above prevViewportTop while its reserved rows are still
|
||||
// visible, so widen the range to include such blocks — otherwise
|
||||
// the stale overlay would survive the repaint and its id would
|
||||
// drop out of previousKittyImageIds, never to be cleaned up.
|
||||
let deleteFrom = prevViewportTop;
|
||||
for (let k = 0; k < prevViewportTop; k++) {
|
||||
const prevLine = this.previousLines[k] ?? "";
|
||||
if (!isImageLine(prevLine)) continue;
|
||||
const blockEnd = k + this.getKittyImageReservedRows(this.previousLines, k) - 1;
|
||||
if (blockEnd >= prevViewportTop) {
|
||||
deleteFrom = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
repaint += this.deleteChangedKittyImages(deleteFrom, this.previousLines.length - 1);
|
||||
// Move the cursor to the top of the screen (old anchor still valid).
|
||||
const cursorScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
|
||||
if (cursorScreenRow > 0) {
|
||||
repaint += `\x1b[${cursorScreenRow}A`;
|
||||
}
|
||||
repaint += "\r";
|
||||
// Rewrite paintCount rows from paintStart. Rows written past the
|
||||
// bottom screen row scroll the terminal: with an advancing anchor
|
||||
// the first `advance` rows (freshly painted) scroll into
|
||||
// scrollback, committing them exactly once. With advance = 0 the
|
||||
// last row gets no trailing \r\n, so nothing scrolls.
|
||||
for (let r = 0; r < paintCount; r++) {
|
||||
if (r > 0) repaint += "\r\n";
|
||||
repaint += "\x1b[2K";
|
||||
const idx = paintStart + r;
|
||||
if (idx < newLines.length) {
|
||||
repaint += newLines[idx]!;
|
||||
}
|
||||
}
|
||||
repaint += "\x1b[?2026l"; // End synchronized output
|
||||
this.terminal.write(repaint);
|
||||
// Re-anchor: screen row r now shows newLines[desiredTop + r].
|
||||
this.cursorRow = Math.max(0, newLines.length - 1);
|
||||
this.hardwareCursorRow = desiredTop + height - 1;
|
||||
this.previousViewportTop = desiredTop;
|
||||
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
||||
this.positionHardwareCursor(cursorPos, newLines.length, desiredTop, height);
|
||||
this.previousLines = newLines;
|
||||
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
||||
this.previousWidth = width;
|
||||
this.previousHeight = height;
|
||||
};
|
||||
|
||||
// Rewinding the viewport anchor below the scrollback high-water mark
|
||||
// (previousViewportTop) repaints rows that scrollback already holds,
|
||||
// and the next scroll commits them again — every rewind duplicates
|
||||
// its span. So a shrink normally keeps the anchor pinned: the content
|
||||
// bottom hovers above the screen bottom with a bounded blank gap that
|
||||
// the next growth naturally fills. The single exception is a collapse
|
||||
// — the content ends at or above the screen top (compaction, clears),
|
||||
// where nothing sensible could be shown otherwise. There the rewind
|
||||
// is unavoidable, and the content has changed so drastically that the
|
||||
// duplicated span is not recognizable as such.
|
||||
const desiredViewportTop = Math.max(0, newLines.length - height);
|
||||
if (prevViewportTop > desiredViewportTop && newLines.length <= prevViewportTop) {
|
||||
repaintViewport(
|
||||
desiredViewportTop,
|
||||
`re-anchor after collapse (viewportTop ${prevViewportTop} -> ${desiredViewportTop}, newLines=${newLines.length})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find first and last changed lines
|
||||
let firstChanged = -1;
|
||||
let lastChanged = -1;
|
||||
|
|
@ -1517,7 +1419,7 @@ export class TUI extends Container {
|
|||
|
||||
// No changes - but still need to update hardware cursor position if it moved
|
||||
if (firstChanged === -1) {
|
||||
this.positionHardwareCursor(cursorPos, newLines.length, prevViewportTop, height);
|
||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||
this.previousViewportTop = prevViewportTop;
|
||||
this.previousHeight = height;
|
||||
return;
|
||||
|
|
@ -1563,7 +1465,7 @@ export class TUI extends Container {
|
|||
this.cursorRow = targetRow;
|
||||
this.hardwareCursorRow = targetRow;
|
||||
}
|
||||
this.positionHardwareCursor(cursorPos, newLines.length, prevViewportTop, height);
|
||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||
this.previousLines = newLines;
|
||||
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
||||
this.previousWidth = width;
|
||||
|
|
@ -1573,63 +1475,11 @@ export class TUI extends Container {
|
|||
}
|
||||
|
||||
// Differential rendering can only touch what was actually visible.
|
||||
// If the first changed line is above the viewport, a destructive full
|
||||
// redraw would clear scrollback and yank the user's viewport (on Windows
|
||||
// Terminal, ESC[3J while scrolled into scrollback jumps to the absolute
|
||||
// top — microsoft/Terminal#20370). Clamp the diff to the visible viewport
|
||||
// and skip above-viewport changes: scrollback keeps stale bytes, but the
|
||||
// user's scroll position is preserved.
|
||||
// If the first changed line is above the previous viewport, we need a full redraw.
|
||||
if (firstChanged < prevViewportTop) {
|
||||
let visibleFirstChanged = -1;
|
||||
for (let i = prevViewportTop; i <= lastChanged; i++) {
|
||||
const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
|
||||
const newLine = i < newLines.length ? newLines[i] : "";
|
||||
if (oldLine !== newLine) {
|
||||
visibleFirstChanged = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (visibleFirstChanged === -1) {
|
||||
logRedraw(`all changes above viewport (firstChanged=${firstChanged} < ${prevViewportTop})`);
|
||||
this.positionHardwareCursor(cursorPos, newLines.length, prevViewportTop, height);
|
||||
this.previousLines = newLines;
|
||||
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
||||
this.previousWidth = width;
|
||||
this.previousHeight = height;
|
||||
this.previousViewportTop = prevViewportTop;
|
||||
return;
|
||||
}
|
||||
// The remaining visible changes are entirely in the deleted tail
|
||||
// region. Reachable while a shrink is pinned: repaint the window
|
||||
// at the pinned anchor, which clears the deleted rows without
|
||||
// scrolling or rewinding.
|
||||
if (visibleFirstChanged >= newLines.length) {
|
||||
repaintViewport(
|
||||
prevViewportTop,
|
||||
`deleted tail within pinned viewport (newLines=${newLines.length}, viewportTop=${prevViewportTop})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A length change above the viewport shifts every line below it.
|
||||
// Painting the shifted lines through the screen would push rows
|
||||
// that are already in scrollback out again, stacking a duplicate
|
||||
// copy there on every shrink/grow cycle (e.g. streaming content
|
||||
// oscillating). Repaint the visible viewport instead: scrollback
|
||||
// keeps the stale old version and the screen shows the fresh
|
||||
// tail. The anchor never rewinds here (that would duplicate as
|
||||
// well): it stays pinned on a shrink, and advances only when the
|
||||
// content has outgrown the pinned window — repaintViewport then
|
||||
// scrolls the skipped rows into scrollback so none are lost.
|
||||
if (newLines.length !== this.previousLines.length) {
|
||||
const repaintTop = Math.max(desiredViewportTop, prevViewportTop);
|
||||
repaintViewport(
|
||||
repaintTop,
|
||||
`re-anchor after above-viewport length change (${this.previousLines.length} -> ${newLines.length}, viewportTop ${prevViewportTop} -> ${repaintTop})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
logRedraw(`clamped firstChanged ${firstChanged} -> ${visibleFirstChanged} (viewportTop=${prevViewportTop})`);
|
||||
firstChanged = visibleFirstChanged;
|
||||
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Render from first changed line to end
|
||||
|
|
@ -1705,20 +1555,12 @@ export class TUI extends Container {
|
|||
buffer += `\x1b[${moveDown}B`;
|
||||
finalCursorRow = newLines.length - 1;
|
||||
}
|
||||
// Only clear what is actually on screen: writing \r\n past the
|
||||
// bottom row would scroll the terminal without updating
|
||||
// viewportTop/hardwareCursorRow, desyncing every later render.
|
||||
// Rows below the screen bottom are invisible and need no clearing.
|
||||
const extraLines = this.previousLines.length - newLines.length;
|
||||
const rowsBelowCursor = height - 1 - (finalCursorRow - viewportTop);
|
||||
const clearLines = Math.max(0, Math.min(extraLines, rowsBelowCursor));
|
||||
for (let i = 0; i < clearLines; i++) {
|
||||
for (let i = newLines.length; i < this.previousLines.length; i++) {
|
||||
buffer += "\r\n\x1b[2K";
|
||||
}
|
||||
// Move cursor back to end of new content
|
||||
if (clearLines > 0) {
|
||||
buffer += `\x1b[${clearLines}A`;
|
||||
}
|
||||
buffer += `\x1b[${extraLines}A`;
|
||||
}
|
||||
|
||||
buffer += "\x1b[?2026l"; // End synchronized output
|
||||
|
|
@ -1765,7 +1607,7 @@ export class TUI extends Container {
|
|||
this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
|
||||
|
||||
// Position hardware cursor for IME
|
||||
this.positionHardwareCursor(cursorPos, newLines.length, this.previousViewportTop, height);
|
||||
this.positionHardwareCursor(cursorPos, newLines.length);
|
||||
|
||||
this.previousLines = newLines;
|
||||
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
|
||||
|
|
@ -1777,15 +1619,8 @@ export class TUI extends Container {
|
|||
* Position the hardware cursor for IME candidate window.
|
||||
* @param cursorPos The cursor position extracted from rendered output, or null
|
||||
* @param totalLines Total number of rendered lines
|
||||
* @param viewportTop Logical row shown on the top screen row after this frame
|
||||
* @param height Terminal height (visible viewport size)
|
||||
*/
|
||||
private positionHardwareCursor(
|
||||
cursorPos: { row: number; col: number } | null,
|
||||
totalLines: number,
|
||||
viewportTop: number,
|
||||
height: number,
|
||||
): void {
|
||||
private positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void {
|
||||
if (!cursorPos || totalLines <= 0) {
|
||||
this.terminal.hideCursor();
|
||||
return;
|
||||
|
|
@ -1795,17 +1630,6 @@ export class TUI extends Container {
|
|||
const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
|
||||
const targetCol = Math.max(0, cursorPos.col);
|
||||
|
||||
// The hardware cursor can only sit inside the visible window. A
|
||||
// marker outside it (e.g. a tall editor poking above a pinned
|
||||
// viewport) must not move the cursor: the ANSI moves would clamp at
|
||||
// the screen edge while hardwareCursorRow recorded the unreachable
|
||||
// row, desyncing every later differential move. Hide the cursor and
|
||||
// keep the bookkeeping on the real cursor row instead.
|
||||
if (targetRow < viewportTop || targetRow >= viewportTop + height) {
|
||||
this.terminal.hideCursor();
|
||||
return;
|
||||
}
|
||||
|
||||
// Move cursor from current position to target
|
||||
const rowDelta = targetRow - this.hardwareCursorRow;
|
||||
let buffer = "";
|
||||
|
|
|
|||
|
|
@ -658,7 +658,7 @@ describe("TUI differential rendering", () => {
|
|||
tui.stop();
|
||||
});
|
||||
|
||||
it("re-anchors without a full redraw when deleted lines move the viewport upward", async () => {
|
||||
it("full re-renders when deleted lines move the viewport upward", async () => {
|
||||
const terminal = new VirtualTerminal(20, 5);
|
||||
const tui = new TUI(terminal);
|
||||
const component = new TestComponent();
|
||||
|
|
@ -674,7 +674,7 @@ describe("TUI differential rendering", () => {
|
|||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.strictEqual(tui.fullRedraws, initialRedraws, "Shrink should re-anchor in place, not full-redraw");
|
||||
assert.ok(tui.fullRedraws > initialRedraws, "Shrink should trigger a full redraw");
|
||||
assert.deepStrictEqual(terminal.getViewport(), ["Line 2", "Line 3", "Line 4", "Line 5", "Line 6"]);
|
||||
|
||||
tui.stop();
|
||||
|
|
@ -696,8 +696,7 @@ describe("TUI differential rendering", () => {
|
|||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.strictEqual(tui.fullRedraws, initialRedraws, "Shrink should re-anchor in place, not full-redraw");
|
||||
assert.deepStrictEqual(terminal.getViewport(), ["Line 0", "Line 1", "", "", ""]);
|
||||
assert.ok(tui.fullRedraws > initialRedraws, "Shrink should reset the viewport with a full redraw");
|
||||
const redrawsAfterShrink = tui.fullRedraws;
|
||||
|
||||
component.lines = ["Line 0", "Line 1", "Line 2"];
|
||||
|
|
@ -741,10 +740,12 @@ describe("TUI differential rendering", () => {
|
|||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.strictEqual(
|
||||
tui.fullRedraws,
|
||||
redrawsBeforeSwitch,
|
||||
"Branch switch should not trigger a full redraw (repainted in place)",
|
||||
// Upstream behavior: a change above the viewport triggers a
|
||||
// destructive full redraw, which repaints the tail of the new
|
||||
// content and leaves no stale rows on screen.
|
||||
assert.ok(
|
||||
tui.fullRedraws > redrawsBeforeSwitch,
|
||||
"Branch switch above the viewport should trigger a full redraw",
|
||||
);
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
|
|
@ -755,55 +756,25 @@ describe("TUI differential rendering", () => {
|
|||
assert.ok(!line.includes("Chat 14"), `Stale "Chat 14" at viewport row ${i}`);
|
||||
}
|
||||
|
||||
// Partial shrinks keep the viewport anchor pinned (rewinding would
|
||||
// duplicate scrollback rows), so the remaining content sits at the
|
||||
// top of the window with blank rows below. The stale "Chat 12/13/14"
|
||||
// rows remain in scrollback but are not visible.
|
||||
// The full redraw shows the tail of the new content with the editor
|
||||
// at the bottom.
|
||||
assert.deepStrictEqual(viewport, [
|
||||
"Chat 5",
|
||||
"Chat 6",
|
||||
"Chat 7",
|
||||
"Chat 8",
|
||||
"Chat 9",
|
||||
"Chat 10",
|
||||
"Chat 11",
|
||||
"Editor 0",
|
||||
"Editor 1",
|
||||
"Editor 2",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
]);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("TUI scrollback preservation", () => {
|
||||
it("does not emit full redraw when changes are above the viewport", async () => {
|
||||
const terminal = new LoggingVirtualTerminal(40, 5);
|
||||
const tui = new TUI(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
|
||||
// Render 8 lines into a height-5 terminal so previousViewportTop > 0
|
||||
// (viewport shows lines 3..7, lines 0..2 are above the viewport).
|
||||
component.lines = ["line0", "line1", "line2", "line3", "line4", "line5", "line6", "line7"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
terminal.clearWrites();
|
||||
|
||||
// Change a line above the viewport (line0); this must not trigger fullRender.
|
||||
component.lines[0] = "line0-changed";
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const writes = terminal.getWrites();
|
||||
assert.ok(!writes.includes("\x1b[3J"), "should not clear scrollback (no ESC[3J)");
|
||||
assert.ok(!writes.includes("\x1b[2J"), "should not full redraw (no ESC[2J)");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Container width clamping", () => {
|
||||
it("clamps non-positive widths to 1 before rendering children", () => {
|
||||
const container = new Container();
|
||||
|
|
|
|||
|
|
@ -1,330 +0,0 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { type Component, TUI } from "../src/tui.ts";
|
||||
import { VirtualTerminal } from "./virtual-terminal.ts";
|
||||
|
||||
class Lines implements Component {
|
||||
private lines: string[];
|
||||
|
||||
constructor(lines: string[]) {
|
||||
this.lines = lines;
|
||||
}
|
||||
|
||||
setLines(lines: string[]): void {
|
||||
this.lines = lines;
|
||||
}
|
||||
|
||||
render(): string[] {
|
||||
return this.lines;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
class WriteCapturingTerminal extends VirtualTerminal {
|
||||
writes: string[] = [];
|
||||
|
||||
override write(data: string): void {
|
||||
this.writes.push(data);
|
||||
super.write(data);
|
||||
}
|
||||
}
|
||||
|
||||
describe("TUI shrinking content", () => {
|
||||
it("clears all rendered lines when content shrinks to zero", async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
const content = new Lines(["first", "second", "third"]);
|
||||
tui.addChild(content);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.ok(terminal.getViewport().some((line) => line.includes("first")));
|
||||
assert.ok(terminal.getViewport().some((line) => line.includes("second")));
|
||||
assert.ok(terminal.getViewport().some((line) => line.includes("third")));
|
||||
|
||||
tui.clear();
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(!viewport.some((line) => line.includes("first")), "first line should be cleared");
|
||||
assert.ok(!viewport.some((line) => line.includes("second")), "second line should be cleared");
|
||||
assert.ok(!viewport.some((line) => line.includes("third")), "third line should be cleared");
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("repaints the viewport when content collapses above it with an above-viewport change", async () => {
|
||||
// Regression: compaction/collapse shrinks 30 lines to 8 (below the
|
||||
// viewport top at 20) while a line above the viewport also changes.
|
||||
// The clamped differential path used to desync the cursor and leave
|
||||
// the viewport blank with the input box gone.
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
const content = new Lines([]);
|
||||
tui.addChild(content);
|
||||
|
||||
content.setLines([...Array.from({ length: 29 }, (_, i) => `L${i}`), "[INPUT-BOX]"]);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
assert.ok(terminal.getViewport().some((line) => line.includes("[INPUT-BOX]")));
|
||||
|
||||
content.setLines(["L0", "L1", "L2", "L3", "L4", "L5-CHANGED", "L6", "[INPUT-BOX]"]);
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(
|
||||
viewport.some((line) => line.includes("[INPUT-BOX]")),
|
||||
`input box should stay visible, got: ${JSON.stringify(viewport)}`,
|
||||
);
|
||||
assert.ok(viewport.some((line) => line.includes("L5-CHANGED")));
|
||||
|
||||
// Scrollback must be preserved (no ESC[3J): old history stays above.
|
||||
assert.ok(
|
||||
terminal.getScrollBuffer().some((line) => line.includes("L15")),
|
||||
"scrollback should keep the old history",
|
||||
);
|
||||
|
||||
// Subsequent renders must land in the right place (self-healing).
|
||||
content.setLines(["L0", "L1", "L2", "L3", "L4", "L5-CHANGED", "L6", "[INPUT-BOX]x"]);
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
assert.ok(
|
||||
terminal.getViewport().some((line) => line.includes("[INPUT-BOX]x")),
|
||||
"render after the collapse should update the input box in place",
|
||||
);
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("preserves the user's scroll position when content collapses while scrolled up", async () => {
|
||||
// While the user is reading scrollback, the collapse repaint must only
|
||||
// touch the live screen area at the bottom of the buffer: no ESC[3J,
|
||||
// no viewport yank. Scrolling back down shows the fresh content.
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
const content = new Lines([]);
|
||||
tui.addChild(content);
|
||||
|
||||
content.setLines([...Array.from({ length: 29 }, (_, i) => `L${i}`), "[INPUT-BOX]"]);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// User scrolls up into scrollback.
|
||||
terminal.scrollViewport(-10);
|
||||
const scrolledPosition = terminal.getScrollPosition();
|
||||
assert.ok(scrolledPosition < 20, "user should be scrolled into scrollback");
|
||||
assert.ok(terminal.getViewport().some((line) => line.includes("L10")));
|
||||
|
||||
content.setLines(["L0", "L1", "L2", "L3", "L4", "L5-CHANGED", "L6", "[INPUT-BOX]"]);
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// The user's scrolled view must not move, and still shows the history.
|
||||
assert.strictEqual(terminal.getScrollPosition(), scrolledPosition, "scroll position should be preserved");
|
||||
assert.ok(terminal.getViewport().some((line) => line.includes("L10")));
|
||||
|
||||
// Scrolling back down reveals the repainted content with the input box.
|
||||
terminal.scrollToBottom();
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(
|
||||
viewport.some((line) => line.includes("[INPUT-BOX]")),
|
||||
`input box should be visible at the bottom, got: ${JSON.stringify(viewport)}`,
|
||||
);
|
||||
assert.ok(viewport.some((line) => line.includes("L5-CHANGED")));
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("deletes a kitty image straddling the viewport top when content collapses", async () => {
|
||||
// A multi-row image can start above the viewport top while its
|
||||
// reserved rows are still visible. The collapse repaint must widen
|
||||
// its image-delete range to that block, or the stale overlay
|
||||
// survives and its id drops out of tracking.
|
||||
const terminal = new WriteCapturingTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
const content = new Lines([]);
|
||||
tui.addChild(content);
|
||||
|
||||
// 30 lines, image line at index 18 with 4 reserved rows (18..21),
|
||||
// straddling the viewport top at 20.
|
||||
const imageLine = "\x1b_Ga=T,i=42,r=4;AAAA\x1b\\";
|
||||
const first = [
|
||||
...Array.from({ length: 18 }, (_, i) => `L${i}`),
|
||||
imageLine,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
...Array.from({ length: 7 }, (_, i) => `L${22 + i}`),
|
||||
"[INPUT-BOX]",
|
||||
];
|
||||
content.setLines(first);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
terminal.writes = [];
|
||||
content.setLines(["L0", "L1", "L2", "L3", "L4", "L5-CHANGED", "L6", "[INPUT-BOX]"]);
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const written = terminal.writes.join("");
|
||||
assert.ok(
|
||||
written.includes("\x1b_Ga=d,d=I,i=42,q=2\x1b\\"),
|
||||
"the straddling image should be deleted during the collapse repaint",
|
||||
);
|
||||
assert.ok(terminal.getViewport().some((line) => line.includes("[INPUT-BOX]")));
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("re-anchors the input box to the screen bottom when content collapses past the viewport top", async () => {
|
||||
// Regression: previousViewportTop only ever grows; when content
|
||||
// collapses to or above the viewport top (compaction, clears) the
|
||||
// viewport must rewind so the tail of the new content is shown.
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
const content = new Lines([]);
|
||||
tui.addChild(content);
|
||||
|
||||
content.setLines([...Array.from({ length: 59 }, (_, i) => `old-${i}`), "[INPUT-BOX]"]);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
assert.ok(terminal.getViewport()[9]!.includes("[INPUT-BOX]"));
|
||||
|
||||
// Collapse 60 -> 30 lines (30 <= viewportTop 50); content is still
|
||||
// taller than the screen, so the tail must land at the screen bottom.
|
||||
content.setLines([...Array.from({ length: 29 }, (_, i) => `new-${i}`), "[INPUT-BOX]"]);
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(
|
||||
viewport[9]!.includes("[INPUT-BOX]"),
|
||||
`input box should sit on the bottom screen row, got: ${JSON.stringify(viewport)}`,
|
||||
);
|
||||
assert.ok(viewport[0]!.includes("new-20"), "viewport should show the tail of the new content");
|
||||
|
||||
// Subsequent renders must land in the right place (self-healing).
|
||||
content.setLines([...Array.from({ length: 29 }, (_, i) => `new-${i}`), "[INPUT-BOX]x"]);
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
assert.ok(terminal.getViewport()[9]!.includes("[INPUT-BOX]x"));
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("does not duplicate scrollback rows on a shrink/grow cycle with above-viewport changes", async () => {
|
||||
// Regression: a shrink re-anchor rewinds viewportTop; if the next
|
||||
// grow with an above-viewport change painted from the rewound top
|
||||
// through the end, rows already in scrollback were pushed out again,
|
||||
// stacking a duplicate copy there on every oscillation.
|
||||
const terminal = new VirtualTerminal(60, 10);
|
||||
const tui = new TUI(terminal);
|
||||
const content = new Lines([]);
|
||||
tui.addChild(content);
|
||||
|
||||
const base = (status: string, tail: string[]): string[] => [
|
||||
...Array.from({ length: 5 }, (_, i) => `L${i}`),
|
||||
status,
|
||||
...Array.from({ length: 48 }, (_, i) => `M${i}`),
|
||||
...tail,
|
||||
"[INPUT]",
|
||||
];
|
||||
|
||||
content.setLines(base("status-A", ["** marker message **"]));
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Shrink by a few rows (activity pane collapsing) -> re-anchor.
|
||||
content.setLines(base("status-A", ["** marker message **"]).slice(0, -4).concat(["[INPUT]"]));
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Grow back with an above-viewport change (reflow/status update).
|
||||
content.setLines(base("status-B", ["** marker message **"]));
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const buffer = terminal.getScrollBuffer();
|
||||
for (const marker of ["M37", "M39", "** marker message **"]) {
|
||||
const count = buffer.filter((line) => line.includes(marker)).length;
|
||||
assert.strictEqual(count, 1, `"${marker}" should appear exactly once, got ${count}`);
|
||||
}
|
||||
const viewport = terminal.getViewport();
|
||||
assert.ok(viewport[9]!.includes("[INPUT]"), "input box should sit on the bottom screen row");
|
||||
assert.ok(viewport.some((line) => line.includes("** marker message **")));
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("keeps the viewport pinned on partial shrinks and lets growth refill the gap", async () => {
|
||||
// Rewinding the anchor would repaint rows scrollback already holds
|
||||
// and duplicate them on the next scroll, so a partial shrink stays
|
||||
// pinned: the input box hovers above a bounded blank gap that the
|
||||
// next growth naturally fills. No rewind, no duplication.
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
const content = new Lines([]);
|
||||
tui.addChild(content);
|
||||
|
||||
content.setLines([...Array.from({ length: 59 }, (_, i) => `old-${i}`), "[INPUT-BOX]"]);
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
assert.ok(terminal.getViewport()[9]!.includes("[INPUT-BOX]"));
|
||||
|
||||
// Shrink by 4 rows; content still spans the pinned viewport (56 > 50).
|
||||
content.setLines([...Array.from({ length: 55 }, (_, i) => `old-${i}`), "[INPUT-BOX]"]);
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// The viewport stays pinned: the input box hovers above blank rows.
|
||||
const pinned = terminal.getViewport();
|
||||
assert.ok(pinned[5]!.includes("[INPUT-BOX]"), `expected pinned gap, got: ${JSON.stringify(pinned)}`);
|
||||
assert.strictEqual(pinned[9], "", "rows below the content should be blank while pinned");
|
||||
|
||||
// Growth fills the gap; the input box returns to the bottom row and
|
||||
// nothing was duplicated in the buffer along the way.
|
||||
content.setLines([...Array.from({ length: 59 }, (_, i) => `old-${i}`), "[INPUT-BOX]"]);
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
const grown = terminal.getViewport();
|
||||
assert.ok(grown[9]!.includes("[INPUT-BOX]"), `input box should be back at the bottom, got: ${JSON.stringify(grown)}`);
|
||||
const buffer = terminal.getScrollBuffer();
|
||||
for (const marker of ["old-40", "old-54", "[INPUT-BOX]"]) {
|
||||
const count = buffer.filter((line) => line.includes(marker)).length;
|
||||
assert.strictEqual(count, 1, `"${marker}" should appear exactly once, got ${count}`);
|
||||
}
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("shows the tail of collapsed content when it is still taller than the screen", async () => {
|
||||
// 100 lines -> 30 lines (still > height 10) with a change above the
|
||||
// viewport: the viewport should show the tail of the new content.
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(terminal);
|
||||
const content = new Lines([]);
|
||||
tui.addChild(content);
|
||||
|
||||
content.setLines(Array.from({ length: 100 }, (_, i) => `old-${i}`));
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const newLines = Array.from({ length: 30 }, (_, i) => `new-${i}`);
|
||||
content.setLines(newLines);
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
|
||||
const viewport = terminal.getViewport();
|
||||
for (let i = 20; i < 30; i++) {
|
||||
assert.ok(
|
||||
viewport.some((line) => line.includes(`new-${i}`)),
|
||||
`tail line new-${i} should be visible, got: ${JSON.stringify(viewport)}`,
|
||||
);
|
||||
}
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
});
|
||||
|
|
@ -135,27 +135,6 @@ export class VirtualTerminal implements Terminal {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate the user scrolling the terminal window (negative = up into scrollback).
|
||||
*/
|
||||
scrollViewport(lines: number): void {
|
||||
this.xterm.scrollLines(lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current scroll position of the user-visible window (buffer.viewportY).
|
||||
*/
|
||||
getScrollPosition(): number {
|
||||
return this.xterm.buffer.active.viewportY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll the user-visible window back to the bottom of the buffer.
|
||||
*/
|
||||
scrollToBottom(): void {
|
||||
this.xterm.scrollToBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush and get viewport - convenience method for tests
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue