Four tightly-scoped hot-path fixes covering the highest-impact items in the reporter's CPU profile (13.1 s profiled / 30 s window): 1. `event-controller.ts:handleEvent` no longer fires a blanket `statusLine.invalidate() + ui.requestRender()` before every session event. The pre-render was a leftover from #4145 when `updateEditorTopBorder()` still eagerly rebuilt the border; the lazy provider added in #4145 made it redundant. It fired on every `message_update`/`tool_execution_update` during streaming — the pre-render's frame ran while the handler was awaiting, then the handler's own `requestRender` scheduled a second identical frame. Every handler that mutates visible state already calls `requestRender()`. 2. `shimmer.ts:shimmerSegments` iterates the segment string in place instead of building a code-point array with `Array.from(seg.text)` every animation frame. Runs of same-tier chars are emitted via a single `slice` per run rather than accumulating into `runBuf`. Surrogate pairs stay atomic — the code-point index still advances by 1 per emoji. Microbench over 30k frames: 45 ms → 18 ms (2.53x), allocation rate down from ~N-per-frame to a handful per frame. New tests cover mixed BMP+surrogate and all-emoji inputs. `Array.from` was the #1 self-time hotspot in the reporter's profile at 10.2%. 3. `Markdown.setText` gains an equality guard mirroring `Text.setText` (returns `false` when `text === #text`). Providers re-emit identical text on ticks with no delta (throttled frames, reconciled tool-execution updates); each of those now short-circuits instead of dropping `#cachedLines` and forcing a full lex + wrap on the accumulated paragraph (the reporter's #3 hotspot at 8.4%). New test asserts render-reference stability + return-value semantics. 4. `SPINNER_RENDER_INTERVAL_MS` aligned with `SPINNER_GLYPH_ADVANCE_MS` (both 80 ms). The previous 33 ms cadence emitted ~2.4 paints per glyph step; the differential-output dedup only skips the write, not the compose walk. Visually identical (glyph advance was already 12.5fps), halves paints during tool execution. Skipped (out of scope for a bug fix, deserve dedicated PRs): - Freezing streaming prefix on single `\n` boundaries — correctness-bound to `\n\n` block separators (CommonMark loose-list continuation). - Compose-phase idle gate + adaptive-backpressure moving-average — need a component-level dirty flag; the 200 ms cap in `#scheduleRender` was set for a reason (#4145 tail-latency guard). Tests updated: two IRC-expiry tests in event-controller-message-start.test.ts that were asserting the pre-render's second `requestRender` call now expect one. Fixes #4353
69 lines
2.5 KiB
TypeScript
69 lines
2.5 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "bun:test";
|
|
import * as settingsModule from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { shimmerText } from "@oh-my-pi/pi-coding-agent/modes/theme/shimmer";
|
|
import type { Theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|
|
|
const testTheme = {
|
|
bold(text: string): string {
|
|
return `\x1b[1m${text}\x1b[22m`;
|
|
},
|
|
fg(color: Parameters<Theme["fg"]>[0], text: string): string {
|
|
return `${this.getFgAnsi(color)}${text}\x1b[39m`;
|
|
},
|
|
getFgAnsi(color: Parameters<Theme["getFgAnsi"]>[0]): string {
|
|
const codes = {
|
|
accent: "\x1b[36m",
|
|
dim: "\x1b[2m",
|
|
muted: "\x1b[90m",
|
|
};
|
|
return codes[color as "accent" | "dim" | "muted"] ?? "";
|
|
},
|
|
};
|
|
|
|
describe("shimmerText", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("uses a supplied raw ANSI color for the shimmer crest", () => {
|
|
vi.spyOn(settingsModule, "isSettingsInitialized").mockReturnValue(false);
|
|
// t chosen so the fixed-velocity band (30 cells/s) crest sits on the char:
|
|
// pos = (333/1000)*30 ≈ 10 = CLASSIC_PADDING, i.e. centered on index 0.
|
|
vi.spyOn(Date, "now").mockReturnValue(333);
|
|
|
|
const rendered = shimmerText("x", testTheme, {
|
|
low: "dim",
|
|
mid: { ansi: "\x1b[38;2;12;34;56m" },
|
|
high: { ansi: "\x1b[38;2;12;34;56m" },
|
|
bold: true,
|
|
});
|
|
|
|
expect(rendered).toContain("\x1b[38;2;12;34;56m");
|
|
expect(Bun.stripANSI(rendered)).toBe("x");
|
|
});
|
|
|
|
it("shimmers a mixed BMP + surrogate-pair message correctly (issue #4353 refactor)", () => {
|
|
// #4353 replaced `Array.from(seg.text)` with in-place UTF-16 iteration to
|
|
// drop the per-frame allocation that dominated the shimmer render cost.
|
|
// A surrogate-pair emoji must still count as one code point (the band
|
|
// index) and stay atomic in the output — an off-by-one would either
|
|
// double-count the pair (mispositioning the band) or emit one lone
|
|
// high-surrogate byte inside an ANSI run.
|
|
vi.spyOn(settingsModule, "isSettingsInitialized").mockReturnValue(false);
|
|
vi.spyOn(Date, "now").mockReturnValue(0);
|
|
|
|
const rendered = shimmerText("a😀b", testTheme);
|
|
// Strip ANSI: the visible payload must be the exact input, preserving the
|
|
// surrogate pair intact.
|
|
expect(Bun.stripANSI(rendered)).toBe("a😀b");
|
|
});
|
|
|
|
it("shimmers an all-emoji message without dropping any code point", () => {
|
|
vi.spyOn(settingsModule, "isSettingsInitialized").mockReturnValue(false);
|
|
vi.spyOn(Date, "now").mockReturnValue(0);
|
|
|
|
const rendered = shimmerText("🎉🌟✨🚀", testTheme);
|
|
expect(Bun.stripANSI(rendered)).toBe("🎉🌟✨🚀");
|
|
});
|
|
});
|