Merge PR #3848: perf(coding-agent): memoize incremental grapheme slicing in streaming reveal (@oldschoola)
This commit is contained in:
@@ -148,6 +148,9 @@
|
||||
- Resolved status inconsistencies between `/extensions`, `/mcp list`, and the dashboard, ensuring MCP server states, allowlists/denylists, and configuration files (like `mcp.json`) stay fully synchronized.
|
||||
- Improved branch-mode task merges to preserve the agent's original commit history (messages and authors) and fixed a bug where merges were rejected due to unrelated dirty changes in the parent checkout.
|
||||
- Fixed an issue where the `Working...` loader spinner would prematurely disappear or fail to re-arm after a subagent (`task`) tool completed or during transient overlays (such as auto-compaction or auto-retry).
|
||||
### Changed
|
||||
|
||||
- Sped up smooth streaming reveal ~10x for large messages: each 30fps tick now slices only the per-step grapheme delta via memoized incremental slicing in `BlockUnitCounter`, instead of re-segmenting the whole revealed prefix every tick.
|
||||
|
||||
## [16.2.6] - 2026-06-29
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AssistantMessageComponent } from "../src/modes/components/assistant-mes
|
||||
import { TranscriptContainer } from "../src/modes/components/transcript-container";
|
||||
import { Settings } from "../src/config/settings";
|
||||
import { getEditorTheme } from "../src/modes/theme/theme";
|
||||
import { buildDisplayMessage, nextStep, visibleUnits } from "../src/modes/controllers/streaming-reveal";
|
||||
import { BlockUnitCounter, buildDisplayMessage, nextStep, visibleUnits } from "../src/modes/controllers/streaming-reveal";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
@@ -55,11 +55,12 @@ bench("WelcomeComponent.render", () => {
|
||||
|
||||
// ── A2: streaming reveal + editor render baselines ──────────────────────────
|
||||
//
|
||||
// Diagnostic series, not a fixed-iteration micro-op. `streamingReveal` proves
|
||||
// or refutes the O(N^2) reveal hypothesis: per-step cost (visibleUnits +
|
||||
// buildDisplayMessage, the work every stream delta/30fps tick does) is sampled
|
||||
// at growing revealed lengths. Rising per-step ms => O(N) per tick => O(N^2)
|
||||
// over the message. Flat per-step => already linear.
|
||||
// Diagnostic series, not a fixed-iteration micro-op. The full-reveal loops
|
||||
// mirror the controller: a per-episode BlockUnitCounter feeds countOf + sliceOf
|
||||
// (memoized, O(delta)/tick). `streamingReveal` (C1) instead measures the DEFAULT
|
||||
// pure-sliceGraphemes path at a fixed revealed length — the un-memoized cost the
|
||||
// counter avoids. Representative controller-path throughput lives in
|
||||
// bench/streaming-throughput.bench.ts.
|
||||
|
||||
function makeMarkdownCorpus(targetGraphemes: number): string {
|
||||
const para =
|
||||
@@ -106,7 +107,7 @@ const REVEAL_CORPUS = makeMarkdownCorpus(6000);
|
||||
const REVEAL_CHECKPOINTS = [1000, 2000, 3000, 4000, 5000, 6000];
|
||||
const STEP_REPS = 40;
|
||||
|
||||
console.log("\nstreamingReveal (isolated C1: visibleUnits + buildDisplayMessage per delta):");
|
||||
console.log("\nstreamingReveal (C1: default pure-slice path, fixed revealed length, no memoization):");
|
||||
for (const n of REVEAL_CHECKPOINTS) {
|
||||
const msg = makeTextMessage(REVEAL_CORPUS.slice(0, n));
|
||||
const revealed = Math.floor(n * 0.9);
|
||||
@@ -117,13 +118,18 @@ for (const n of REVEAL_CHECKPOINTS) {
|
||||
console.log(` len=${n}: ${ms.toFixed(4)}ms/step`);
|
||||
}
|
||||
|
||||
// Real streaming cost: text GROWS every tick, so Markdown's text-keyed cache
|
||||
// misses each step (the actual interactive path). Total ms to fully reveal an
|
||||
// N-grapheme message in nextStep increments — the number C1+C2 reduce.
|
||||
console.log("\nstreamingRevealFull (C1+C2: full incremental reveal, growing text => cache-miss/tick):");
|
||||
// Controller path: a per-episode BlockUnitCounter memoizes count + slice, so
|
||||
// buildDisplayMessage is O(delta)/tick. The Markdown render (component.render)
|
||||
// still re-lexes the growing text each step here (no { transient: true }), so
|
||||
// total ms is dominated by the render, not the slice. Total ms to fully reveal
|
||||
// an N-grapheme message in nextStep increments.
|
||||
console.log("\nstreamingRevealFull (controller-path counter + Markdown render, growing text):");
|
||||
try {
|
||||
for (const n of REVEAL_CHECKPOINTS) {
|
||||
const full = makeTextMessage(REVEAL_CORPUS.slice(0, n));
|
||||
const counter = new BlockUnitCounter();
|
||||
const countOf = (index: number, text: string): number => counter.count(index, text);
|
||||
const sliceOf = (index: number, text: string, units: number): string => counter.slice(index, text, units);
|
||||
const total = visibleUnits(full, false);
|
||||
const component = new AssistantMessageComponent();
|
||||
const start = Bun.nanoseconds();
|
||||
@@ -131,7 +137,7 @@ try {
|
||||
let steps = 0;
|
||||
while (revealed < total) {
|
||||
revealed = Math.min(total, revealed + nextStep(total - revealed));
|
||||
component.updateContent(buildDisplayMessage(full, revealed, false));
|
||||
component.updateContent(buildDisplayMessage(full, revealed, false, countOf, sliceOf));
|
||||
component.render(WIDTH);
|
||||
steps++;
|
||||
}
|
||||
@@ -153,6 +159,9 @@ try {
|
||||
const thinking = makeMarkdownCorpus(2500);
|
||||
for (const n of [2000, 4000, 6000]) {
|
||||
const full = makeThinkingPlusText(thinking, REVEAL_CORPUS.slice(0, n));
|
||||
const counter = new BlockUnitCounter();
|
||||
const countOf = (index: number, text: string): number => counter.count(index, text);
|
||||
const sliceOf = (index: number, text: string, units: number): string => counter.slice(index, text, units);
|
||||
const total = visibleUnits(full, false);
|
||||
const component = new AssistantMessageComponent();
|
||||
const start = Bun.nanoseconds();
|
||||
@@ -160,7 +169,7 @@ try {
|
||||
let steps = 0;
|
||||
while (revealed < total) {
|
||||
revealed = Math.min(total, revealed + nextStep(total - revealed));
|
||||
component.updateContent(buildDisplayMessage(full, revealed, false));
|
||||
component.updateContent(buildDisplayMessage(full, revealed, false, countOf, sliceOf));
|
||||
component.render(WIDTH);
|
||||
steps++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Streaming-reveal per-tick compute benchmark.
|
||||
*
|
||||
* Mirrors `StreamingRevealController`'s per-tick work: re-count the visible
|
||||
* units of the target (memoized `BlockUnitCounter.count`) and rebuild the
|
||||
* display message (`buildDisplayMessage`), which slices each text block to the
|
||||
* revealed prefix. One `BlockUnitCounter` is created per episode and shared by
|
||||
* `countOf` + `sliceOf`, exactly as the controller holds one `#unitCounter` per
|
||||
* streaming episode.
|
||||
*
|
||||
* The Markdown render is intentionally excluded: every controller tick passes
|
||||
* `{ transient: true }` to `updateContent`, which disables the L2 cache and code
|
||||
* highlighting, so the dominant per-tick cost is the slice of the growing prefix
|
||||
* (re-segmented from offset 0 by the baseline `sliceGraphemes`). This isolates
|
||||
* exactly that path.
|
||||
*
|
||||
* Metric (lower is better): total wall-clock to fully reveal one representative
|
||||
* large assistant message through the controller's `nextStep` progression,
|
||||
* averaged over episodes. `reveal_ms_per_step` is the same work divided by the
|
||||
* number of reveal ticks.
|
||||
*
|
||||
* Run: bun run packages/coding-agent/bench/streaming-throughput.bench.ts
|
||||
*/
|
||||
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
|
||||
import { BlockUnitCounter, buildDisplayMessage, nextStep } from "../src/modes/controllers/streaming-reveal";
|
||||
|
||||
const HIDE_THINKING = false;
|
||||
const PROSE_ONLY = true;
|
||||
const WARMUP_EPISODES = 6;
|
||||
const MEASURE_EPISODES = 40;
|
||||
|
||||
/** Prose + code + accented text + multibyte grapheme clusters (ZWJ families,
|
||||
* flag sequences, skin-tone modifiers, CJK), representative of LLM output that
|
||||
* makes Intl.Segmenter do real per-cluster work. */
|
||||
const CHUNK = `Here is an overview of the rendering pipeline changes.
|
||||
|
||||
The streaming reveal controller now advances the revealed prefix each tick. Consider the helper:
|
||||
|
||||
\`\`\`ts
|
||||
function sliceToUnits(text: string, units: number): string {
|
||||
\tlet end = 0;
|
||||
\tfor (const { index, segment } of segmenter.segment(text)) {
|
||||
\t\tif (--units < 0) break;
|
||||
\t\tend = index + segment.length;
|
||||
\t}
|
||||
\treturn text.slice(0, end);
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
This handles café, naïve résumés, and emoji clusters like the 👨👩👧👦 family, the 🏳️🌈 flag, and the 👩🏽 skin-tone modifier. CJK text such as 日本語のテスト also segments correctly, and the heart ❤️ beats steadily. Each grapheme cluster is one user-perceived character: "👨👩👧👦" is a single unit, not seven code points. The decomposed sequence e followed by a combining acute accent (e + \\u0301) is likewise a single cluster, distinct from the precomposed form.
|
||||
|
||||
The adaptive step is \`nextStep = max(3, ceil(backlog / 8))\`, so the tick count stays roughly constant while the per-tick slice cost grows with the rendered prefix. Incremental slicing lowers that per-update cost from the prefix length to the per-step delta.
|
||||
|
||||
`;
|
||||
|
||||
function makeMessage(textBlocks: string[]): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: textBlocks.map(text => ({ type: "text" as const, text })),
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "mock",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Total visible graphemes of the target's text blocks via the counter (mirrors
|
||||
* the controller's `#visibleUnits` for text-only blocks). */
|
||||
function textUnits(target: AssistantMessage, counter: BlockUnitCounter): number {
|
||||
let total = 0;
|
||||
for (let i = 0; i < target.content.length; i++) {
|
||||
const block = target.content[i];
|
||||
if (block?.type === "text") total += counter.count(i, block.text);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Drive one full reveal episode: a fresh counter shared by countOf + sliceOf,
|
||||
* an initial render at revealed = 0 (mirrors `begin`), then the `nextStep`
|
||||
* catch-up loop (mirrors `#tick`). Returns the number of reveal ticks. */
|
||||
function revealEpisode(target: AssistantMessage): number {
|
||||
const counter = new BlockUnitCounter();
|
||||
const countOf = (index: number, text: string): number => counter.count(index, text);
|
||||
const sliceOf = (index: number, text: string, units: number): string => counter.slice(index, text, units);
|
||||
buildDisplayMessage(target, 0, HIDE_THINKING, PROSE_ONLY, countOf, sliceOf);
|
||||
let revealed = 0;
|
||||
let ticks = 0;
|
||||
for (;;) {
|
||||
const total = textUnits(target, counter);
|
||||
if (revealed >= total) break;
|
||||
revealed = Math.min(total, revealed + nextStep(total - revealed));
|
||||
buildDisplayMessage(target, revealed, HIDE_THINKING, PROSE_ONLY, countOf, sliceOf);
|
||||
ticks += 1;
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
// Two text blocks of differing lengths exercise multi-block counter indexing.
|
||||
const target = makeMessage([CHUNK.repeat(16), CHUNK.repeat(12)]);
|
||||
const sizingCounter = new BlockUnitCounter();
|
||||
const graphemes = textUnits(target, sizingCounter);
|
||||
|
||||
for (let episode = 0; episode < WARMUP_EPISODES; episode++) revealEpisode(target);
|
||||
|
||||
let totalTicks = 0;
|
||||
const start = performance.now();
|
||||
for (let episode = 0; episode < MEASURE_EPISODES; episode++) totalTicks += revealEpisode(target);
|
||||
const elapsedMs = performance.now() - start;
|
||||
|
||||
const msPerEpisode = elapsedMs / MEASURE_EPISODES;
|
||||
const msPerStep = elapsedMs / totalTicks;
|
||||
|
||||
console.log(`METRIC reveal_ms_per_episode=${msPerEpisode.toFixed(4)}`);
|
||||
console.log(`METRIC reveal_ms_per_step=${msPerStep.toFixed(5)}`);
|
||||
console.log(`ASI graphemes=${graphemes} episodes=${MEASURE_EPISODES} ticks_per_episode=${(totalTicks / MEASURE_EPISODES).toFixed(2)} warmup=${WARMUP_EPISODES}`);
|
||||
console.log(`(reveal: ${graphemes} graphemes, ${(totalTicks / MEASURE_EPISODES).toFixed(1)} ticks/episode, ${msPerEpisode.toFixed(3)} ms/episode, ${msPerStep.toFixed(4)} ms/step)`);
|
||||
@@ -11,6 +11,7 @@ export const CATCHUP_FRAMES = 8;
|
||||
type AssistantContentBlock = AssistantMessage["content"][number];
|
||||
type DisplayThinkingContentBlock = Extract<AssistantContentBlock, { type: "thinking" }> & { rawThinking?: string };
|
||||
type StreamingRevealComponent = Pick<AssistantMessageComponent, "updateContent">;
|
||||
type GraphemeSlicer = (index: number, text: string, units: number) => string;
|
||||
|
||||
type StreamingRevealControllerOptions = {
|
||||
getSmoothStreaming(): boolean;
|
||||
@@ -44,12 +45,29 @@ function countGraphemesFrom(text: string, start: number): { count: number; tailS
|
||||
}
|
||||
return { count, tailStart };
|
||||
}
|
||||
/** Segment `text` from code-unit offset `start`, walking up to `clusters`
|
||||
* graphemes. Returns the code-unit END of the final cluster walked, its START
|
||||
* (`lastStart`), and how many clusters were found (`count` may be less than
|
||||
* `clusters` if the suffix is shorter than requested). */
|
||||
function segmentFrom(text: string, start: number, clusters: number): { end: number; lastStart: number; count: number } {
|
||||
let count = 0;
|
||||
let lastStart = start;
|
||||
let end = start;
|
||||
for (const seg of getSegmenter().segment(start === 0 ? text : text.slice(start))) {
|
||||
count += 1;
|
||||
lastStart = start + seg.index;
|
||||
end = start + seg.index + seg.segment.length;
|
||||
if (count >= clusters) break;
|
||||
}
|
||||
return { end, lastStart, count };
|
||||
}
|
||||
|
||||
/** Memoizes per-block grapheme counts across reveal ticks. Streaming blocks only
|
||||
* grow by appending, and an append can only alter the final grapheme cluster of
|
||||
* the previous text, so only the suffix from that cluster needs re-segmenting. */
|
||||
class BlockUnitCounter {
|
||||
export class BlockUnitCounter {
|
||||
#entries = new Map<number, { text: string; count: number; tailStart: number }>();
|
||||
#sliceEntries = new Map<number, { text: string; units: number; end: number; lastStart: number }>();
|
||||
|
||||
count(index: number, text: string): number {
|
||||
const entry = this.#entries.get(index);
|
||||
@@ -69,6 +87,29 @@ class BlockUnitCounter {
|
||||
|
||||
reset(): void {
|
||||
this.#entries.clear();
|
||||
this.#sliceEntries.clear();
|
||||
}
|
||||
/** Slice `text` to its first `units` graphemes. Memoized across reveal ticks:
|
||||
* streaming blocks grow only by appending and the reveal target advances
|
||||
* monotonically, so a previously sliced prefix is reused and only the suffix
|
||||
* from the boundary cluster is re-segmented. Only an exact (text, units) hit
|
||||
* skips segmentation entirely — an append can extend the boundary cluster, so
|
||||
* the incremental path still re-segments from that cluster's start. */
|
||||
slice(index: number, text: string, units: number): string {
|
||||
if (units <= 0 || text.length === 0) return "";
|
||||
const entry = this.#sliceEntries.get(index);
|
||||
if (entry !== undefined && entry.text === text && entry.units === units) {
|
||||
return entry.end >= text.length ? text : text.slice(0, entry.end);
|
||||
}
|
||||
if (entry !== undefined && (entry.text === text || text.startsWith(entry.text)) && units >= entry.units) {
|
||||
const extra = units - entry.units + 1;
|
||||
const seg = segmentFrom(text, entry.lastStart, extra);
|
||||
this.#sliceEntries.set(index, { text, units, end: seg.end, lastStart: seg.lastStart });
|
||||
return seg.end >= text.length ? text : text.slice(0, seg.end);
|
||||
}
|
||||
const seg = segmentFrom(text, 0, units);
|
||||
this.#sliceEntries.set(index, { text, units, end: seg.end, lastStart: seg.lastStart });
|
||||
return seg.end >= text.length ? text : text.slice(0, seg.end);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,20 +145,24 @@ function revealTextBlock(
|
||||
block: Extract<AssistantContentBlock, { type: "text" }>,
|
||||
remaining: number,
|
||||
units: number,
|
||||
index: number,
|
||||
sliceOf: GraphemeSlicer,
|
||||
): AssistantContentBlock {
|
||||
if (remaining <= 0) return block.text.length === 0 ? block : { ...block, text: "" };
|
||||
if (remaining >= units) return block;
|
||||
return { ...block, text: sliceGraphemes(block.text, remaining) };
|
||||
return { ...block, text: sliceOf(index, block.text, remaining) };
|
||||
}
|
||||
|
||||
function revealThinkingBlock(
|
||||
block: Extract<AssistantContentBlock, { type: "thinking" }>,
|
||||
remaining: number,
|
||||
units: number,
|
||||
index: number,
|
||||
sliceOf: GraphemeSlicer,
|
||||
): AssistantContentBlock {
|
||||
if (remaining <= 0) return block.thinking.length === 0 ? block : { ...block, thinking: "" };
|
||||
if (remaining >= units) return block;
|
||||
return { ...block, thinking: sliceGraphemes(block.thinking, remaining) };
|
||||
return { ...block, thinking: sliceOf(index, block.thinking, remaining) };
|
||||
}
|
||||
|
||||
export function buildDisplayMessage(
|
||||
@@ -126,6 +171,7 @@ export function buildDisplayMessage(
|
||||
hideThinking: boolean,
|
||||
proseOnly = true,
|
||||
countOf: (index: number, text: string) => number = (_index, text) => countGraphemes(text),
|
||||
sliceOf: GraphemeSlicer = (_index, text, units) => sliceGraphemes(text, units),
|
||||
): AssistantMessage {
|
||||
let remaining = Math.max(0, Math.floor(revealed));
|
||||
const content: AssistantContentBlock[] = [];
|
||||
@@ -133,7 +179,7 @@ export function buildDisplayMessage(
|
||||
const block = target.content[i]!;
|
||||
if (block.type === "text") {
|
||||
const units = countOf(i, block.text);
|
||||
content.push(revealTextBlock(block, remaining, units));
|
||||
content.push(revealTextBlock(block, remaining, units, i, sliceOf));
|
||||
remaining = Math.max(0, remaining - units);
|
||||
} else if (block.type === "thinking" && !hideThinking) {
|
||||
const formatted = formatThinkingForDisplay(block.thinking, proseOnly);
|
||||
@@ -144,7 +190,7 @@ export function buildDisplayMessage(
|
||||
thinking: formatted,
|
||||
rawThinking: block.thinking,
|
||||
};
|
||||
content.push(revealThinkingBlock(displayBlock, remaining, units));
|
||||
content.push(revealThinkingBlock(displayBlock, remaining, units, i, sliceOf));
|
||||
remaining = Math.max(0, remaining - units);
|
||||
} else {
|
||||
content.push(block);
|
||||
@@ -174,6 +220,8 @@ export class StreamingRevealController {
|
||||
#smoothStreaming = true;
|
||||
readonly #unitCounter = new BlockUnitCounter();
|
||||
readonly #countOf = (index: number, text: string): number => this.#unitCounter.count(index, text);
|
||||
readonly #sliceOf = (index: number, text: string, units: number): string =>
|
||||
this.#unitCounter.slice(index, text, units);
|
||||
|
||||
constructor(options: StreamingRevealControllerOptions) {
|
||||
this.#getSmoothStreaming = options.getSmoothStreaming;
|
||||
@@ -181,6 +229,16 @@ export class StreamingRevealController {
|
||||
this.#getProseOnlyThinking = options.getProseOnlyThinking;
|
||||
this.#requestRender = options.requestRender;
|
||||
}
|
||||
#build(target: AssistantMessage, revealed: number): AssistantMessage {
|
||||
return buildDisplayMessage(
|
||||
target,
|
||||
revealed,
|
||||
this.#hideThinkingBlock,
|
||||
this.#proseOnlyThinking,
|
||||
this.#countOf,
|
||||
this.#sliceOf,
|
||||
);
|
||||
}
|
||||
|
||||
begin(component: StreamingRevealComponent, message: AssistantMessage): void {
|
||||
this.stop();
|
||||
@@ -192,10 +250,7 @@ export class StreamingRevealController {
|
||||
this.#smoothStreaming = this.#getSmoothStreaming();
|
||||
if (!this.#smoothStreaming) {
|
||||
const total = this.#visibleUnits(message);
|
||||
component.updateContent(
|
||||
buildDisplayMessage(message, total, this.#hideThinkingBlock, this.#proseOnlyThinking, this.#countOf),
|
||||
{ transient: true },
|
||||
);
|
||||
component.updateContent(this.#build(message, total), { transient: true });
|
||||
return;
|
||||
}
|
||||
const total = this.#visibleUnits(message);
|
||||
@@ -203,18 +258,9 @@ export class StreamingRevealController {
|
||||
// A tool call is a transcript-order boundary: finish any leading
|
||||
// assistant text before EventController renders the separate tool card.
|
||||
this.#revealed = total;
|
||||
component.updateContent(
|
||||
buildDisplayMessage(
|
||||
message,
|
||||
this.#revealed,
|
||||
this.#hideThinkingBlock,
|
||||
this.#proseOnlyThinking,
|
||||
this.#countOf,
|
||||
),
|
||||
{
|
||||
transient: true,
|
||||
},
|
||||
);
|
||||
component.updateContent(this.#build(message, this.#revealed), {
|
||||
transient: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.#renderCurrent();
|
||||
@@ -229,10 +275,7 @@ export class StreamingRevealController {
|
||||
if (!this.#component) return;
|
||||
if (!this.#smoothStreaming) {
|
||||
const total = this.#visibleUnits(message);
|
||||
this.#component.updateContent(
|
||||
buildDisplayMessage(message, total, this.#hideThinkingBlock, this.#proseOnlyThinking, this.#countOf),
|
||||
{ transient: true },
|
||||
);
|
||||
this.#component.updateContent(this.#build(message, total), { transient: true });
|
||||
return;
|
||||
}
|
||||
const total = this.#visibleUnits(message);
|
||||
@@ -241,18 +284,9 @@ export class StreamingRevealController {
|
||||
// assistant text before EventController renders the separate tool card.
|
||||
this.#revealed = total;
|
||||
this.#stopTimer();
|
||||
this.#component.updateContent(
|
||||
buildDisplayMessage(
|
||||
message,
|
||||
this.#revealed,
|
||||
this.#hideThinkingBlock,
|
||||
this.#proseOnlyThinking,
|
||||
this.#countOf,
|
||||
),
|
||||
{
|
||||
transient: true,
|
||||
},
|
||||
);
|
||||
this.#component.updateContent(this.#build(message, this.#revealed), {
|
||||
transient: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.#revealed > total) {
|
||||
@@ -309,16 +343,7 @@ export class StreamingRevealController {
|
||||
// Every controller render is an in-flight streaming snapshot, even when
|
||||
// smooth reveal has temporarily caught up to the current target. The
|
||||
// message_end handler performs the only stable non-transient render.
|
||||
this.#component.updateContent(
|
||||
buildDisplayMessage(
|
||||
this.#target,
|
||||
this.#revealed,
|
||||
this.#hideThinkingBlock,
|
||||
this.#proseOnlyThinking,
|
||||
this.#countOf,
|
||||
),
|
||||
{ transient: true },
|
||||
);
|
||||
this.#component.updateContent(this.#build(this.#target, this.#revealed), { transient: true });
|
||||
}
|
||||
|
||||
#syncTimer(total = this.#target ? this.#visibleUnits(this.#target) : 0): void {
|
||||
@@ -356,12 +381,9 @@ export class StreamingRevealController {
|
||||
return;
|
||||
}
|
||||
this.#revealed = Math.min(total, this.#revealed + nextStep(total - this.#revealed));
|
||||
component.updateContent(
|
||||
buildDisplayMessage(target, this.#revealed, this.#hideThinkingBlock, this.#proseOnlyThinking, this.#countOf),
|
||||
{
|
||||
transient: true,
|
||||
},
|
||||
);
|
||||
component.updateContent(this.#build(target, this.#revealed), {
|
||||
transient: true,
|
||||
});
|
||||
this.#requestRender();
|
||||
if (this.#revealed >= total) {
|
||||
this.#stopTimer();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
|
||||
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
|
||||
import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message";
|
||||
import {
|
||||
BlockUnitCounter,
|
||||
buildDisplayMessage,
|
||||
CATCHUP_FRAMES,
|
||||
MIN_STEP,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
visibleUnits,
|
||||
} from "@oh-my-pi/pi-coding-agent/modes/controllers/streaming-reveal";
|
||||
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import { getSegmenter } from "@oh-my-pi/pi-tui";
|
||||
|
||||
beforeAll(async () => {
|
||||
await initTheme(false);
|
||||
@@ -297,3 +299,101 @@ describe("streaming reveal", () => {
|
||||
expect(requestRender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
/** Pure Intl.Segmenter grapheme count, independent of BlockUnitCounter's memoization. */
|
||||
function refCount(text: string): number {
|
||||
let n = 0;
|
||||
for (const _segment of getSegmenter().segment(text)) n += 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Pure Intl.Segmenter grapheme slice, independent of BlockUnitCounter's memoization. */
|
||||
function refSlice(text: string, units: number): string {
|
||||
if (units <= 0) return "";
|
||||
let n = 0;
|
||||
for (const { index, segment } of getSegmenter().segment(text)) {
|
||||
n += 1;
|
||||
if (n >= units) return text.slice(0, index + segment.length);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
describe("BlockUnitCounter.slice", () => {
|
||||
it("matches a pure segmenter reference for fixed-text growing units", () => {
|
||||
const counter = new BlockUnitCounter();
|
||||
const text = "café 👨👩👧👦 naïve 日本語 ❤️";
|
||||
const total = refCount(text);
|
||||
for (let units = 0; units <= total; units++) {
|
||||
expect(counter.slice(0, text, units)).toBe(refSlice(text, units));
|
||||
}
|
||||
});
|
||||
|
||||
it("re-segments the boundary cluster when an append extends it (no stale slice)", () => {
|
||||
const counter = new BlockUnitCounter();
|
||||
// "a" cached at 1 grapheme; appending a combining mark keeps it 1 cluster
|
||||
// but changes the cluster's code units — the slice must not return stale "a".
|
||||
expect(counter.slice(0, "a", 1)).toBe("a");
|
||||
expect(counter.slice(0, "a\u0301", 1)).toBe("a\u0301");
|
||||
// A ZWJ append merges the previous final cluster into a family emoji.
|
||||
const merged = new BlockUnitCounter();
|
||||
expect(merged.slice(0, "ab👨", 3)).toBe("ab👨");
|
||||
expect(merged.slice(0, "ab👨\u200D👩x", 3)).toBe("ab👨\u200D👩");
|
||||
});
|
||||
|
||||
it("keeps separate block indices independent", () => {
|
||||
const counter = new BlockUnitCounter();
|
||||
const a = "hello world";
|
||||
const b = "café résumé";
|
||||
const ta = refCount(a);
|
||||
const tb = refCount(b);
|
||||
for (let units = 0; units <= ta; units++) expect(counter.slice(0, a, units)).toBe(refSlice(a, units));
|
||||
for (let units = 0; units <= tb; units++) expect(counter.slice(1, b, units)).toBe(refSlice(b, units));
|
||||
// Re-slicing block 0 after touching block 1 still matches the reference.
|
||||
expect(counter.slice(0, a, ta)).toBe(a);
|
||||
});
|
||||
|
||||
it("matches the reference after a shrink and regrow", () => {
|
||||
const counter = new BlockUnitCounter();
|
||||
const text = "the quick brown fox jumps over";
|
||||
const total = refCount(text);
|
||||
expect(counter.slice(0, text, total)).toBe(text);
|
||||
expect(counter.slice(0, text, 2)).toBe(refSlice(text, 2));
|
||||
expect(counter.slice(0, text, total - 1)).toBe(refSlice(text, total - 1));
|
||||
});
|
||||
|
||||
it("matches the reference when the text is fully replaced", () => {
|
||||
const counter = new BlockUnitCounter();
|
||||
expect(counter.slice(0, "first block of text", 3)).toBe(refSlice("first block of text", 3));
|
||||
expect(counter.slice(0, "completely different café content", 5)).toBe(
|
||||
refSlice("completely different café content", 5),
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the reference under seeded append + monotonic reveal (fuzz)", () => {
|
||||
// Deterministic PRNG so the fuzz is reproducible across runs.
|
||||
let state = 0x1234abcd;
|
||||
const rand = (): number => {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
return ((state >>> 0) % 100000) / 100000;
|
||||
};
|
||||
// Appendable chunks include lone combining marks / ZWJ so appends randomly
|
||||
// merge into the previous boundary cluster, stressing that invariant.
|
||||
const chunks = ["a", "bc ", "e", "\u0301", "👨", "\u200D👩", "日", "本", "❤️", "xy", " ", "z"];
|
||||
const counter = new BlockUnitCounter();
|
||||
let text = "";
|
||||
let revealed = 0;
|
||||
for (let step = 0; step < 400; step++) {
|
||||
if (rand() < 0.6 || text.length === 0) {
|
||||
text += chunks[Math.floor(rand() * chunks.length)]!;
|
||||
}
|
||||
const total = refCount(text);
|
||||
// Monotonic reveal advance, with an occasional reset to a small value
|
||||
// to exercise the full re-segment path.
|
||||
revealed = rand() < 0.05 ? Math.floor(rand() * 3) : Math.min(total, revealed + 1 + Math.floor(rand() * 6));
|
||||
if (revealed < 0) revealed = 0;
|
||||
expect(counter.slice(0, text, revealed)).toBe(refSlice(text, revealed));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user