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:
roboomp
2026-08-15 13:17:49 +00:00
parent ffd53ff92a
commit 62eb6d40b8
6 changed files with 392 additions and 19 deletions
+4
View File
@@ -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();
}
});
});
+4
View File
@@ -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
View File
@@ -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);
+207
View File
@@ -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();