fix(tui): bounded magic-keyword shimmer cpu
- Routed focused editor animation through cursor-aware direct row writes. - Preserved cursor marker bookkeeping and native scrollback across marker changes. - Added fixed-geometry, fallback, and editor integration regressions. Fixes #8646
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed focused `ultrathink`, `orchestrate`, and `workflowz` shimmer frames repainting the full TUI every 70 ms, causing high CPU usage while composing prompts on WSL2 ([#8646](https://github.com/can1357/oh-my-pi/issues/8646)).
|
||||
|
||||
## [17.3.4] - 2026-08-14
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -814,7 +814,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.editor.onAutocompleteUpdate = () => {
|
||||
this.ui.requestRender();
|
||||
};
|
||||
this.editor.setShimmerRepaintHandler(() => this.ui.requestComponentRender(this.editor));
|
||||
this.editor.setShimmerRepaintHandler(() => this.ui.requestDirectWrite(this.editor));
|
||||
this.#syncEditorMaxHeight();
|
||||
this.#resizeHandler = () => {
|
||||
this.#syncEditorMaxHeight();
|
||||
@@ -4252,7 +4252,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
nextEditor.onAutocompleteUpdate = () => {
|
||||
this.ui.requestRender();
|
||||
};
|
||||
nextEditor.setShimmerRepaintHandler(() => this.ui.requestComponentRender(this.editor));
|
||||
nextEditor.setShimmerRepaintHandler(() => this.ui.requestDirectWrite(nextEditor));
|
||||
nextEditor.setTopBorderProvider(availableWidth => this.statusLine.getTopBorder(availableWidth));
|
||||
nextEditor.setMaxHeight(this.#computeEditorMaxHeight());
|
||||
if (this.historyStorage) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, setSystemTime, vi } from "bun:test";
|
||||
import * as path from "node:path";
|
||||
import { Agent } from "@oh-my-pi/pi-agent-core";
|
||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
@@ -9,10 +9,67 @@ import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { TUI } from "@oh-my-pi/pi-tui";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
import { StressRenderScheduler } from "../../tui/test/render-stress-scheduler";
|
||||
import { VirtualTerminal } from "../../tui/test/virtual-terminal";
|
||||
|
||||
class TestModalEditor extends CustomEditor {}
|
||||
|
||||
class RenderCountingTUI extends TUI {
|
||||
renderCount = 0;
|
||||
|
||||
override render(width: number): readonly string[] {
|
||||
this.renderCount++;
|
||||
return super.render(width);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectTwoDirectShimmerFrames(
|
||||
tui: RenderCountingTUI,
|
||||
terminal: VirtualTerminal,
|
||||
writes: readonly string[],
|
||||
keyword: string,
|
||||
): Promise<void> {
|
||||
const renderCount = tui.renderCount;
|
||||
const viewport = terminal.getViewport().map(row => Bun.stripANSI(row).trimEnd());
|
||||
const bufferPosition = terminal.getBufferPosition();
|
||||
const scrollback = terminal
|
||||
.getScrollBuffer()
|
||||
.slice(0, bufferPosition.baseY)
|
||||
.map(row => Bun.stripANSI(row).trimEnd());
|
||||
const cursor = terminal.getCursor();
|
||||
const writesBeforeFirstPhase = writes.length;
|
||||
|
||||
expect(viewport.join("\n")).toContain(keyword);
|
||||
expect(bufferPosition.baseY).toBeGreaterThan(0);
|
||||
|
||||
vi.advanceTimersByTime(CustomEditor.SHIMMER_FRAME_MS);
|
||||
await terminal.flush();
|
||||
const firstPhaseWrites = writes.slice(writesBeforeFirstPhase);
|
||||
expect(firstPhaseWrites.length).toBeGreaterThan(0);
|
||||
expect(firstPhaseWrites.join("")).toContain("\x1b[38");
|
||||
|
||||
const writesBeforeSecondPhase = writes.length;
|
||||
vi.advanceTimersByTime(CustomEditor.SHIMMER_FRAME_MS);
|
||||
await terminal.flush();
|
||||
const secondPhaseWrites = writes.slice(writesBeforeSecondPhase);
|
||||
expect(secondPhaseWrites.length).toBeGreaterThan(0);
|
||||
expect(secondPhaseWrites.join("")).toContain("\x1b[38");
|
||||
expect(secondPhaseWrites.join("")).not.toBe(firstPhaseWrites.join(""));
|
||||
|
||||
expect(tui.renderCount).toBe(renderCount);
|
||||
expect(terminal.getViewport().map(row => Bun.stripANSI(row).trimEnd())).toEqual(viewport);
|
||||
expect(terminal.getBufferPosition()).toEqual(bufferPosition);
|
||||
expect(
|
||||
terminal
|
||||
.getScrollBuffer()
|
||||
.slice(0, bufferPosition.baseY)
|
||||
.map(row => Bun.stripANSI(row).trimEnd()),
|
||||
).toEqual(scrollback);
|
||||
expect(terminal.getCursor()).toEqual(cursor);
|
||||
}
|
||||
|
||||
describe("InteractiveMode.setEditorComponent", () => {
|
||||
let tempDir: TempDir;
|
||||
let authStorage: AuthStorage;
|
||||
@@ -73,4 +130,51 @@ describe("InteractiveMode.setEditorComponent", () => {
|
||||
expect(mode.editor.onEscape).toBeDefined();
|
||||
expect(refreshSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("direct-writes focused shimmer frames without disturbing terminal state before or after replacement", async () => {
|
||||
const terminal = new VirtualTerminal(80, 8, 1_000);
|
||||
terminal.write(Array.from({ length: 12 }, (_unused, index) => `seed-${index}\r\n`).join(""));
|
||||
const writes: string[] = [];
|
||||
const write = terminal.write.bind(terminal);
|
||||
vi.spyOn(terminal, "write").mockImplementation((data: string) => {
|
||||
writes.push(data);
|
||||
write(data);
|
||||
});
|
||||
const scheduler = new StressRenderScheduler();
|
||||
const tui = new RenderCountingTUI(terminal, true, { renderScheduler: scheduler });
|
||||
const initialEditor = mode.editor;
|
||||
let replacementEditor: CustomEditor | undefined;
|
||||
|
||||
vi.spyOn(mode, "refreshSlashCommandState").mockResolvedValue();
|
||||
vi.useFakeTimers();
|
||||
setSystemTime(0);
|
||||
try {
|
||||
mode.ui = tui;
|
||||
initialEditor.setUseTerminalCursor(true);
|
||||
initialEditor.magicKeywordsEnabledOverride = true;
|
||||
initialEditor.setText("please orchestrate this draft");
|
||||
tui.addChild(mode.editorContainer);
|
||||
tui.setFocus(initialEditor);
|
||||
tui.start();
|
||||
await scheduler.drain(terminal);
|
||||
|
||||
await expectTwoDirectShimmerFrames(tui, terminal, writes, "orchestrate");
|
||||
initialEditor.setShimmerRepaintHandler(undefined);
|
||||
|
||||
mode.setEditorComponent((_tui, editorTheme) => new TestModalEditor(editorTheme));
|
||||
replacementEditor = mode.editor;
|
||||
replacementEditor.magicKeywordsEnabledOverride = true;
|
||||
replacementEditor.setText("please workflowz this draft");
|
||||
await scheduler.drain(terminal);
|
||||
|
||||
await expectTwoDirectShimmerFrames(tui, terminal, writes, "workflowz");
|
||||
} finally {
|
||||
initialEditor.setShimmerRepaintHandler(undefined);
|
||||
replacementEditor?.setShimmerRepaintHandler(undefined);
|
||||
tui.stop();
|
||||
await terminal.flush();
|
||||
vi.useRealTimers();
|
||||
setSystemTime();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed marker-bearing focused components falling back from direct row writes to full TUI renders, while preserving hardware-cursor position and native scrollback across marker changes ([#8646](https://github.com/can1357/oh-my-pi/issues/8646)).
|
||||
|
||||
## [17.3.4] - 2026-08-14
|
||||
|
||||
### Fixed
|
||||
|
||||
+70
-16
@@ -1772,13 +1772,76 @@ export class TUI extends Container {
|
||||
markers.length = keep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the cursor markers contributed by one fixed-size root segment.
|
||||
* The ledger stays sorted by absolute frame row; entries belonging to later
|
||||
* siblings move as one tail and otherwise retain their identity.
|
||||
*/
|
||||
#replaceFrameCursorMarkers(startRow: number, lines: readonly string[]): void {
|
||||
const markers = this.#frameCursorMarkers;
|
||||
const endRow = startRow + lines.length;
|
||||
let intervalStart = 0;
|
||||
while (intervalStart < markers.length && markers[intervalStart]!.row < startRow) intervalStart++;
|
||||
let intervalEnd = intervalStart;
|
||||
while (intervalEnd < markers.length && markers[intervalEnd]!.row < endRow) intervalEnd++;
|
||||
|
||||
let nextCount = 0;
|
||||
for (let row = 0; row < lines.length; row++) {
|
||||
if (lines[row]!.includes(CURSOR_MARKER)) nextCount++;
|
||||
}
|
||||
|
||||
const previousCount = intervalEnd - intervalStart;
|
||||
const delta = nextCount - previousCount;
|
||||
const previousLength = markers.length;
|
||||
if (delta > 0) {
|
||||
markers.length = previousLength + delta;
|
||||
for (let index = previousLength - 1; index >= intervalEnd; index--) {
|
||||
markers[index + delta] = markers[index]!;
|
||||
}
|
||||
} else if (delta < 0) {
|
||||
for (let index = intervalEnd; index < previousLength; index++) {
|
||||
markers[index + delta] = markers[index]!;
|
||||
}
|
||||
markers.length = previousLength + delta;
|
||||
}
|
||||
|
||||
const reusableCount = Math.min(previousCount, nextCount);
|
||||
let markerSlot = intervalStart;
|
||||
for (let row = 0; row < lines.length; row++) {
|
||||
const line = lines[row]!;
|
||||
const markerIndex = line.indexOf(CURSOR_MARKER);
|
||||
if (markerIndex === -1) continue;
|
||||
const absoluteRow = startRow + row;
|
||||
const col = visibleWidth(line.slice(0, markerIndex));
|
||||
if (markerSlot < intervalStart + reusableCount) {
|
||||
const marker = markers[markerSlot]!;
|
||||
marker.row = absoluteRow;
|
||||
marker.col = col;
|
||||
} else {
|
||||
markers[markerSlot] = { row: absoluteRow, col };
|
||||
}
|
||||
markerSlot++;
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip every internal cursor sentinel from one rendered row. */
|
||||
#stripCursorMarkers(line: string, markerIndex = line.indexOf(CURSOR_MARKER)): string {
|
||||
if (markerIndex === -1) return line;
|
||||
let stripped = line;
|
||||
while (markerIndex !== -1) {
|
||||
stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
|
||||
markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one row to the composed frame, stripping CURSOR_MARKER occurrences
|
||||
* (internal sentinels that must never reach the terminal, the committed
|
||||
* prefix, or the resync audit) and recording the first marker's position.
|
||||
*/
|
||||
#ingestFrameRow(line: string): void {
|
||||
let markerIndex = line.indexOf(CURSOR_MARKER);
|
||||
const markerIndex = line.indexOf(CURSOR_MARKER);
|
||||
if (markerIndex === -1) {
|
||||
this.#composedFrame.push(line);
|
||||
return;
|
||||
@@ -1787,12 +1850,7 @@ export class TUI extends Container {
|
||||
row: this.#composedFrame.length,
|
||||
col: visibleWidth(line.slice(0, markerIndex)),
|
||||
});
|
||||
let stripped = line;
|
||||
while (markerIndex !== -1) {
|
||||
stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length);
|
||||
markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex);
|
||||
}
|
||||
this.#composedFrame.push(stripped);
|
||||
this.#composedFrame.push(this.#stripCursorMarkers(line, markerIndex));
|
||||
}
|
||||
|
||||
#syncTerminalCursorMode(component: Component | null): void {
|
||||
@@ -2558,12 +2616,6 @@ export class TUI extends Container {
|
||||
this.requestComponentRender(component);
|
||||
return;
|
||||
}
|
||||
for (const line of nextLines) {
|
||||
if (line.includes(CURSOR_MARKER)) {
|
||||
this.requestComponentRender(component);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let firstChanged = -1;
|
||||
let lastChanged = -1;
|
||||
@@ -2571,8 +2623,9 @@ export class TUI extends Container {
|
||||
for (let i = 0; i < nextLines.length; i++) {
|
||||
const frameRow = segment.start + i;
|
||||
const raw = nextLines[i]!;
|
||||
const prepared = this.#prepareLine(raw, width);
|
||||
this.#composedFrame[frameRow] = raw;
|
||||
const composed = this.#stripCursorMarkers(raw);
|
||||
const prepared = this.#prepareLine(composed, width);
|
||||
this.#composedFrame[frameRow] = composed;
|
||||
this.#preparedMeta[frameRow] = prepared;
|
||||
this.#preparedFrame[frameRow] = prepared.line;
|
||||
if (previousWindow[screenStart + i] === prepared.line) continue;
|
||||
@@ -2580,7 +2633,8 @@ export class TUI extends Container {
|
||||
if (firstChanged === -1) firstChanged = i;
|
||||
lastChanged = i;
|
||||
}
|
||||
segments[segmentIndex] = { ...segment, lines: nextLines };
|
||||
this.#replaceFrameCursorMarkers(segment.start, nextLines);
|
||||
segment.lines = nextLines;
|
||||
this.#preparedValidRows = Math.max(this.#preparedValidRows, segment.start + nextLines.length);
|
||||
this.#renderStablePrefixRows = Math.min(this.#renderStablePrefixRows, segment.start);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import {
|
||||
type Component,
|
||||
Container,
|
||||
CURSOR_MARKER,
|
||||
Editor,
|
||||
type Focusable,
|
||||
type NativeScrollbackCommittedRows,
|
||||
@@ -81,6 +82,16 @@ function visible(term: VirtualTerminal): string[] {
|
||||
return strip(term.getViewport()).filter(row => row.length > 0);
|
||||
}
|
||||
|
||||
function captureWrites(term: VirtualTerminal): string[] {
|
||||
const writes: string[] = [];
|
||||
const write = term.write.bind(term);
|
||||
term.write = (data: string): void => {
|
||||
writes.push(data);
|
||||
write(data);
|
||||
};
|
||||
return writes;
|
||||
}
|
||||
|
||||
class RenderCountingTUI extends TUI {
|
||||
renders = 0;
|
||||
|
||||
@@ -538,6 +549,202 @@ describe("TUI.requestDirectWrite", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("directly strips markers while updating ANSI and wide-grapheme cursor geometry", async () => {
|
||||
const term = new VirtualTerminal(40, 5, 1_000);
|
||||
const writes = captureWrites(term);
|
||||
const scheduler = new StressRenderScheduler();
|
||||
const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler });
|
||||
const head = new CountingLines(["head"]);
|
||||
const editor = new CountingLines(["plain"]);
|
||||
const footer = new CountingLines(["footer"]);
|
||||
tui.addChild(head);
|
||||
tui.addChild(editor);
|
||||
tui.addChild(footer);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await scheduler.drain(term);
|
||||
const tuiRenders = tui.renders;
|
||||
writes.length = 0;
|
||||
|
||||
editor.set([`\x1b[31m好a${CURSOR_MARKER}b${CURSOR_MARKER}\x1b[0m`]);
|
||||
tui.requestDirectWrite(editor);
|
||||
await scheduler.drain(term);
|
||||
|
||||
expect(strip(term.getViewport())).toEqual(["head", "好ab", "footer", "", ""]);
|
||||
expect(term.getCursor()).toEqual({ row: 1, col: 3 });
|
||||
expect(tui.renders).toBe(tuiRenders);
|
||||
expect(writes.join("")).not.toContain(CURSOR_MARKER);
|
||||
|
||||
writes.length = 0;
|
||||
editor.set(["done"]);
|
||||
tui.requestDirectWrite(editor);
|
||||
await scheduler.drain(term);
|
||||
|
||||
expect(strip(term.getViewport())).toEqual(["head", "done", "footer", "", ""]);
|
||||
expect(tui.renders).toBe(tuiRenders);
|
||||
expect(writes.join("")).toContain("\x1b[?25l");
|
||||
expect(writes.join("")).not.toContain(CURSOR_MARKER);
|
||||
} finally {
|
||||
tui.stop();
|
||||
await term.flush();
|
||||
}
|
||||
});
|
||||
|
||||
it("moves a marker without repainting unchanged row bytes", async () => {
|
||||
const term = new VirtualTerminal(40, 4, 1_000);
|
||||
const writes = captureWrites(term);
|
||||
const scheduler = new StressRenderScheduler();
|
||||
const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler });
|
||||
const editor = new CountingLines([`ab${CURSOR_MARKER}cd`, "efgh"]);
|
||||
tui.addChild(editor);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await scheduler.drain(term);
|
||||
expect(strip(term.getViewport())).toEqual(["abcd", "efgh", "", ""]);
|
||||
expect(term.getCursor()).toEqual({ row: 0, col: 2 });
|
||||
const tuiRenders = tui.renders;
|
||||
writes.length = 0;
|
||||
|
||||
editor.set(["abcd", `e${CURSOR_MARKER}fgh`]);
|
||||
tui.requestDirectWrite(editor);
|
||||
await scheduler.drain(term);
|
||||
|
||||
expect(strip(term.getViewport())).toEqual(["abcd", "efgh", "", ""]);
|
||||
expect(term.getCursor()).toEqual({ row: 1, col: 1 });
|
||||
expect(tui.renders).toBe(tuiRenders);
|
||||
expect(writes.join("")).not.toContain("abcd");
|
||||
expect(writes.join("")).not.toContain(CURSOR_MARKER);
|
||||
} finally {
|
||||
tui.stop();
|
||||
await term.flush();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves later sibling precedence across marker interval growth, shrink, and recompose", async () => {
|
||||
const term = new VirtualTerminal(40, 8, 1_000);
|
||||
const writes = captureWrites(term);
|
||||
const scheduler = new StressRenderScheduler();
|
||||
const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler });
|
||||
const earlier = new CountingLines(["early"]);
|
||||
const target = new CountingLines(["t0", "t1", "t2"]);
|
||||
const later = new CountingLines([`later${CURSOR_MARKER}`]);
|
||||
tui.addChild(earlier);
|
||||
tui.addChild(target);
|
||||
tui.addChild(later);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await scheduler.drain(term);
|
||||
expect(term.getCursor()).toEqual({ row: 4, col: 5 });
|
||||
const tuiRenders = tui.renders;
|
||||
writes.length = 0;
|
||||
|
||||
target.set([`${CURSOR_MARKER}t0`, `t${CURSOR_MARKER}1`, `t2${CURSOR_MARKER}`]);
|
||||
tui.requestDirectWrite(target);
|
||||
await scheduler.drain(term);
|
||||
expect(term.getCursor()).toEqual({ row: 4, col: 5 });
|
||||
expect(tui.renders).toBe(tuiRenders);
|
||||
|
||||
target.set(["t0", `t${CURSOR_MARKER}1`, "t2"]);
|
||||
tui.requestDirectWrite(target);
|
||||
await scheduler.drain(term);
|
||||
expect(term.getCursor()).toEqual({ row: 4, col: 5 });
|
||||
expect(tui.renders).toBe(tuiRenders);
|
||||
|
||||
later.set(["later"]);
|
||||
tui.requestDirectWrite(later);
|
||||
await scheduler.drain(term);
|
||||
expect(term.getCursor()).toEqual({ row: 2, col: 1 });
|
||||
expect(tui.renders).toBe(tuiRenders);
|
||||
expect(writes.join("")).not.toContain(CURSOR_MARKER);
|
||||
|
||||
const targetRenders = target.renders;
|
||||
writes.length = 0;
|
||||
earlier.set(["early-new"]);
|
||||
tui.requestComponentRender(earlier);
|
||||
await scheduler.drain(term);
|
||||
|
||||
expect(strip(term.getViewport())).toEqual(["early-new", "t0", "t1", "t2", "later", "", "", ""]);
|
||||
expect(term.getCursor()).toEqual({ row: 2, col: 1 });
|
||||
expect(tui.renders).toBeGreaterThan(tuiRenders);
|
||||
expect(target.renders).toBe(targetRenders);
|
||||
expect(writes.join("")).toContain("\x1b[?25h");
|
||||
expect(writes.join("")).not.toContain(CURSOR_MARKER);
|
||||
} finally {
|
||||
tui.stop();
|
||||
await term.flush();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps non-empty native scrollback byte-for-byte stable during marker direct writes", async () => {
|
||||
const term = new VirtualTerminal(30, 4, 1_000);
|
||||
const writes = captureWrites(term);
|
||||
const scheduler = new StressRenderScheduler();
|
||||
const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler });
|
||||
const transcript = new CountingLines(Array.from({ length: 8 }, (_unused, row) => `history-${row}`));
|
||||
const editor = new CountingLines(["anim-0"]);
|
||||
tui.addChild(transcript);
|
||||
tui.addChild(editor);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await scheduler.drain(term);
|
||||
const beforeBuffer = term.getScrollBuffer();
|
||||
const beforeHistory = beforeBuffer.slice(0, Math.max(0, beforeBuffer.length - term.rows));
|
||||
expect(beforeHistory.length).toBeGreaterThan(0);
|
||||
expect(beforeHistory.some(row => row.trimEnd().length > 0)).toBe(true);
|
||||
const tuiRenders = tui.renders;
|
||||
writes.length = 0;
|
||||
|
||||
editor.set([`anim-1${CURSOR_MARKER}`]);
|
||||
tui.requestDirectWrite(editor);
|
||||
await scheduler.drain(term);
|
||||
|
||||
const afterBuffer = term.getScrollBuffer();
|
||||
const afterHistory = afterBuffer.slice(0, Math.max(0, afterBuffer.length - term.rows));
|
||||
expect(afterHistory).toEqual(beforeHistory);
|
||||
expect(strip(term.getViewport())).toEqual(["history-5", "history-6", "history-7", "anim-1"]);
|
||||
expect(term.getCursor()).toEqual({ row: 3, col: 6 });
|
||||
expect(tui.renders).toBe(tuiRenders);
|
||||
expect(writes.join("")).not.toContain(CURSOR_MARKER);
|
||||
} finally {
|
||||
tui.stop();
|
||||
await term.flush();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back safely when marker-bearing output changes row count", async () => {
|
||||
const term = new VirtualTerminal(40, 5, 1_000);
|
||||
const writes = captureWrites(term);
|
||||
const scheduler = new StressRenderScheduler();
|
||||
const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler });
|
||||
const head = new CountingLines(["head"]);
|
||||
const editor = new CountingLines([`one${CURSOR_MARKER}`]);
|
||||
tui.addChild(head);
|
||||
tui.addChild(editor);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await scheduler.drain(term);
|
||||
const tuiRenders = tui.renders;
|
||||
writes.length = 0;
|
||||
|
||||
editor.set([`one${CURSOR_MARKER}`, `two${CURSOR_MARKER}`]);
|
||||
tui.requestDirectWrite(editor);
|
||||
await scheduler.drain(term);
|
||||
|
||||
expect(strip(term.getViewport())).toEqual(["head", "one", "two", "", ""]);
|
||||
expect(term.getCursor()).toEqual({ row: 2, col: 3 });
|
||||
expect(tui.renders).toBeGreaterThan(tuiRenders);
|
||||
expect(writes.join("")).not.toContain(CURSOR_MARKER);
|
||||
} finally {
|
||||
tui.stop();
|
||||
await term.flush();
|
||||
}
|
||||
});
|
||||
|
||||
it("directly rewrites fully live anchored status segments", async () => {
|
||||
const term = new VirtualTerminal(40, 8, 1_000);
|
||||
const scheduler = new StressRenderScheduler();
|
||||
|
||||
Reference in New Issue
Block a user