diffusionpi 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,964 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ // Diffusion LLM servers (e.g. DiffusionGemma on vLLM) denoise a whole canvas
4
+ // internally and only stream committed tokens when a canvas converges, so
5
+ // clients receive bursts separated by silent denoising intervals.
6
+ //
7
+ // Live mode (truthful): when the server exposes the /v1/diffusion/events SSE
8
+ // side channel (vLLM --diffusion-stream-canvas), this widget renders the real
9
+ // intermediate canvas per denoising step: accepted tokens mixed with the
10
+ // sampler's actual renoise tokens, converging to the committed text.
11
+ //
12
+ // Simulated mode (fallback, labeled): without the side channel, intermediate
13
+ // states never leave the engine, so the widget shows glyph noise during the
14
+ // real denoising silence and animates each commit burst. Burst boundaries,
15
+ // commit timing, and step counts are real; the glyphs are illustrative.
16
+ //
17
+ // Server URLs resolve at turn start: the PI_DIFFUSION_CANVAS_EVENTS_URL and
18
+ // PI_DIFFUSION_CANVAS_METRICS_URL environment variables win, otherwise both
19
+ // derive from the active model's baseUrl (an OpenAI-compatible ".../v1").
20
+ let metricsUrl: string | null = null;
21
+ let eventsUrl: string | null = null;
22
+
23
+ function resolveServerUrls(ctx: ExtensionContext): void {
24
+ const baseUrl = ctx.model?.baseUrl;
25
+ metricsUrl =
26
+ process.env["PI_DIFFUSION_CANVAS_METRICS_URL"] ?? serverUrlFromBase(baseUrl, "/metrics");
27
+ eventsUrl =
28
+ process.env["PI_DIFFUSION_CANVAS_EVENTS_URL"] ??
29
+ serverUrlFromBase(baseUrl, "/v1/diffusion/events");
30
+ }
31
+
32
+ function serverUrlFromBase(baseUrl: string | undefined, path: string): string | null {
33
+ if (baseUrl === undefined || baseUrl.length === 0) {
34
+ return null;
35
+ }
36
+ const origin = baseUrl.replace(/\/v1\/?$/u, "").replace(/\/$/u, "");
37
+ return origin.length === 0 ? null : origin + path;
38
+ }
39
+
40
+ const widgetId = "pi-diffusion-canvas";
41
+ const tickMs = 90;
42
+ const maxRows = 4;
43
+ // Live mode shows the entire canvas being denoised (a ~256-token block can
44
+ // take ~10 rows), so its cap is much larger than the simulated window.
45
+ const liveMaxRows = 16;
46
+ const minResolveMs = 300;
47
+ const maxResolveMs = 1500;
48
+ const defaultResolveMs = 1200;
49
+ const metricsTimeoutMs = 5000;
50
+ const maxBufferedCanvases = 8;
51
+ // After a commit, canvas events of the committed block can still be in
52
+ // flight on the SSE connection (it races the completion stream on a separate
53
+ // socket); rendering one would duplicate the settled text. Events carrying a
54
+ // block ordinal are filtered exactly; without one, drop events for a short
55
+ // grace window after each commit.
56
+ const commitGraceMs = 250;
57
+ // The server never streams the converged canvas (commit steps emit no
58
+ // event), so the widget renders it itself: the committed text stays bright
59
+ // for a moment before muting into the settled document.
60
+ const flashMs = 450;
61
+ // Rows of settled context kept above the seam (where settled text meets the
62
+ // canvas) in the live window.
63
+ const liveSettledContextRows = 2;
64
+ const noiseGlyphs = "abcdefghijklmnopqrstuvwxyz0123456789#%&@$+=~?";
65
+
66
+ type ActiveCell = {
67
+ readonly char: string;
68
+ readonly resolveAt: number;
69
+ };
70
+
71
+ type TurnState = {
72
+ readonly startedAt: number;
73
+ firstBurstAt: number | undefined;
74
+ lastBurstAt: number | undefined;
75
+ burstCount: number;
76
+ totalChars: number;
77
+ settledText: string;
78
+ active: ActiveCell[];
79
+ intervals: number[];
80
+ liveMode: boolean;
81
+ rowsHighWater: number;
82
+ responseId: string | undefined;
83
+ latestCanvasByRequest: Map<string, CanvasEvent>;
84
+ liveText: string | undefined;
85
+ liveStep: number;
86
+ liveBlock: number | undefined;
87
+ // Staleness floors, set at each commit: canvas events at or below them
88
+ // belong to an already-committed block. Steps and block ordinals are
89
+ // per-request, so both reset when the completion's request id changes.
90
+ stepFloor: number;
91
+ blockFloor: number | undefined;
92
+ suppressCanvasUntil: number;
93
+ // The most recent commit renders bright until flashUntil (the converged
94
+ // canvas frame the server never streams).
95
+ flashUntil: number;
96
+ flashChars: number;
97
+ done: boolean;
98
+ };
99
+
100
+ type DiffusionCounters = {
101
+ readonly steps: number;
102
+ readonly positions: number;
103
+ readonly committed: number;
104
+ };
105
+
106
+ type RenderCellKind = "settled" | "resolved" | "noise" | "ahead";
107
+
108
+ type RenderCell = {
109
+ readonly char: string;
110
+ readonly kind: RenderCellKind;
111
+ readonly width: number;
112
+ };
113
+
114
+ type WidgetTheme = {
115
+ fg(color: "accent" | "muted" | "dim" | "text", text: string): string;
116
+ };
117
+
118
+ type CanvasEvent = {
119
+ readonly requestId: string;
120
+ readonly step: number;
121
+ readonly text: string;
122
+ // Commit ordinal of the block this snapshot belongs to. Newer servers
123
+ // include it; when present it identifies stale snapshots exactly.
124
+ readonly block: number | undefined;
125
+ };
126
+
127
+ export default function diffusionpiDiffusionCanvas(pi: ExtensionAPI): void {
128
+ let current: TurnState | undefined;
129
+ let ticker: ReturnType<typeof setInterval> | undefined;
130
+ let requestRender = (): void => undefined;
131
+ let widgetInstalled = false;
132
+ let countersAtTurnStart: Promise<DiffusionCounters | undefined> | undefined;
133
+ let stepsPerCanvas: number | undefined;
134
+ let eventsAbort: AbortController | undefined;
135
+ let subscribedRequestId: string | undefined;
136
+
137
+ function installWidget(ctx: ExtensionContext): void {
138
+ if (widgetInstalled) {
139
+ return;
140
+ }
141
+ widgetInstalled = true;
142
+ ctx.ui.setWidget(widgetId, (tui, theme) => {
143
+ requestRender = () => {
144
+ tui.requestRender();
145
+ };
146
+ return {
147
+ render: (width: number) => renderWidget(width, theme),
148
+ invalidate: () => undefined
149
+ };
150
+ });
151
+ }
152
+
153
+ function startTicker(): void {
154
+ ticker ??= setInterval(() => {
155
+ requestRender();
156
+ if (current !== undefined && current.done && animationDone(current)) {
157
+ stopTicker();
158
+ }
159
+ }, tickMs);
160
+ }
161
+
162
+ function stopTicker(): void {
163
+ if (ticker !== undefined) {
164
+ clearInterval(ticker);
165
+ ticker = undefined;
166
+ }
167
+ }
168
+
169
+ // Subscribe scoped to one request id: the server then never sends other
170
+ // clients' canvas states to this process. The subscription is (re)opened
171
+ // whenever the current completion's id becomes known, so nothing is
172
+ // subscribed while no id is held.
173
+ function openEventStream(requestId: string): void {
174
+ if (eventsUrl === null) {
175
+ return;
176
+ }
177
+ if (subscribedRequestId === requestId && eventsAbort !== undefined) {
178
+ return;
179
+ }
180
+ closeEventStream();
181
+ subscribedRequestId = requestId;
182
+ const abort = new AbortController();
183
+ eventsAbort = abort;
184
+ const separator = eventsUrl.includes("?") ? "&" : "?";
185
+ const url = `${eventsUrl}${separator}request_id=${encodeURIComponent(requestId)}`;
186
+ void consumeEventStream(url, abort.signal, (event) => {
187
+ const state = current;
188
+ if (state === undefined || state.done) {
189
+ return;
190
+ }
191
+ // A server that predates the request_id query parameter ignores it and
192
+ // broadcasts every request, so filtering here stays as defense. Buffer
193
+ // per request id (the correlated id can change mid-turn or arrive late
194
+ // on servers that ignore the X-Request-Id header), but bound the buffer
195
+ // so a busy shared server cannot grow it without limit: evict the
196
+ // oldest foreign entry, never the correlated one.
197
+ state.latestCanvasByRequest.delete(event.requestId);
198
+ state.latestCanvasByRequest.set(event.requestId, event);
199
+ if (state.latestCanvasByRequest.size > maxBufferedCanvases) {
200
+ for (const requestId of state.latestCanvasByRequest.keys()) {
201
+ if (requestId !== state.responseId) {
202
+ state.latestCanvasByRequest.delete(requestId);
203
+ break;
204
+ }
205
+ }
206
+ }
207
+ refreshLiveCanvas(state);
208
+ requestRender();
209
+ }).catch(() => {
210
+ // Side channel unavailable (unpatched server or flag off): the widget
211
+ // stays in the labeled simulated mode.
212
+ });
213
+ }
214
+
215
+ // The SSE subscription is scoped server-side to this turn's request id;
216
+ // this filter is defense in depth for servers that predate the request_id
217
+ // query parameter and still broadcast every request. Display only the
218
+ // canvas belonging to this turn's current completion; an uncorrelated
219
+ // canvas is never rendered, since on a shared server it could be another
220
+ // client's text. The id is known before the first denoising step because
221
+ // this extension assigns it via the X-Request-Id header (see the
222
+ // before_provider_headers handler); Pi's assistant stream additionally
223
+ // reports the server-authoritative id as partial.responseId with the first
224
+ // committed chunk, which corrects the prediction on servers that ignore
225
+ // the header.
226
+ function refreshLiveCanvas(state: TurnState): void {
227
+ if (state.responseId === undefined) {
228
+ return;
229
+ }
230
+ const event = state.latestCanvasByRequest.get(state.responseId);
231
+ if (event === undefined) {
232
+ // No canvas yet for the current completion (e.g. a new completion
233
+ // started after a tool call); drop any stale one.
234
+ state.liveText = undefined;
235
+ return;
236
+ }
237
+ if (isStaleCanvas(state, event)) {
238
+ // A snapshot of an already-committed block that raced the commit
239
+ // chunk; rendering it would duplicate the settled text.
240
+ return;
241
+ }
242
+ if (!state.liveMode) {
243
+ // If the event stream came up after commits already animated in
244
+ // simulated mode, those committed chars still sit in the resolve
245
+ // animation; settle them so the live renderer (which ignores the
246
+ // animation cells) keeps the committed prefix visible.
247
+ state.settledText += state.active.map((cell) => cell.char).join("");
248
+ state.active = [];
249
+ state.liveMode = true;
250
+ }
251
+ state.liveText = event.text;
252
+ state.liveStep = event.step;
253
+ state.liveBlock = event.block;
254
+ }
255
+
256
+ function closeEventStream(): void {
257
+ eventsAbort?.abort();
258
+ eventsAbort = undefined;
259
+ subscribedRequestId = undefined;
260
+ }
261
+
262
+ // The canvas rows come first and the stats line sits below them, so the
263
+ // document flows straight from Pi's streamed message into the resolving
264
+ // canvas with nothing in between.
265
+ function renderWidget(width: number, theme: WidgetTheme): string[] {
266
+ const state = current;
267
+ if (state === undefined) {
268
+ return [];
269
+ }
270
+ const cols = Math.max(16, width - 2);
271
+ const statsLine = " " + theme.fg("dim", truncate(headerText(state), cols));
272
+ // Once the turn is over and the last canvas has resolved, collapse to the
273
+ // stats line: the full text is already visible in the message above.
274
+ if (state.done && animationDone(state)) {
275
+ return [statsLine];
276
+ }
277
+ const rows = canvasRows(state, cols, theme);
278
+ // Keep the widget height stable while active: pad to the tallest layout
279
+ // seen this turn (it only ratchets up when a bigger canvas arrives). The
280
+ // TUI renders differentially, so a widget that grew and shrank with the
281
+ // canvas text every frame would leave a trail of stale frames in the
282
+ // terminal scrollback; rewriting a fixed set of rows in place leaves
283
+ // none.
284
+ state.rowsHighWater = Math.max(state.rowsHighWater, rows.length);
285
+ while (rows.length < state.rowsHighWater) {
286
+ rows.push("");
287
+ }
288
+ return [...rows.map((row) => " " + row), statsLine];
289
+ }
290
+
291
+ function headerText(state: TurnState): string {
292
+ const mode = state.liveMode ? "live" : "simulated";
293
+ // The server's step counter is monotonic per request; subtracting the
294
+ // floor recorded at the last commit yields the step within the canvas
295
+ // currently being denoised.
296
+ const canvasStep = Math.max(state.liveStep - state.stepFloor, 0);
297
+ if (state.firstBurstAt === undefined) {
298
+ if (state.done) {
299
+ return "diffusion canvas | no output";
300
+ }
301
+ const waited = ((Date.now() - state.startedAt) / 1000).toFixed(1);
302
+ if (state.liveMode) {
303
+ return `diffusion canvas | live | denoising canvas 1, step ${String(canvasStep)}... ${waited}s`;
304
+ }
305
+ return `diffusion canvas | simulated | denoising canvas 1 server-side... ${waited}s | text appears when the canvas commits`;
306
+ }
307
+ const tokens = Math.ceil(state.totalChars / 4);
308
+ const parts = [
309
+ "diffusion canvas",
310
+ mode,
311
+ `${String(state.burstCount)} commits`,
312
+ `~${String(Math.max(Math.round(tokens / state.burstCount), 1))} tok/commit`
313
+ ];
314
+ const interval = medianOf(state.intervals);
315
+ if (interval !== undefined) {
316
+ parts.push(`${(interval / 1000).toFixed(1)}s/commit`);
317
+ }
318
+ const elapsedSeconds = Math.max(
319
+ ((state.lastBurstAt ?? state.startedAt) - state.startedAt) / 1000,
320
+ 0.001
321
+ );
322
+ parts.push(`~${(tokens / elapsedSeconds).toFixed(1)} tok/s`);
323
+ if (stepsPerCanvas !== undefined) {
324
+ // Prometheus counters are server-wide, so this is an aggregate over
325
+ // everything the server ran during the turn, not per-request.
326
+ parts.push(`${stepsPerCanvas.toFixed(1)} steps/canvas (server)`);
327
+ }
328
+ if (state.done) {
329
+ parts.push("done");
330
+ } else if (state.liveMode) {
331
+ parts.push(
332
+ `denoising canvas ${String(state.burstCount + 1)}, step ${String(canvasStep)}...`
333
+ );
334
+ } else {
335
+ parts.push(`denoising canvas ${String(state.burstCount + 1)}...`);
336
+ }
337
+ return parts.join(" | ");
338
+ }
339
+
340
+ pi.on("turn_start", (_event, ctx) => {
341
+ // Component-factory widgets only render in the terminal UI; other modes
342
+ // (RPC, print) must not start tickers, metrics polling, or the SSE feed.
343
+ if (ctx.mode !== "tui") {
344
+ return;
345
+ }
346
+ resolveServerUrls(ctx);
347
+ const state = newTurnState();
348
+ current = state;
349
+ stepsPerCanvas = undefined;
350
+ countersAtTurnStart = fetchCounters();
351
+ installWidget(ctx);
352
+ // The widget header already shows live turn status (canvas, step,
353
+ // elapsed), so Pi's built-in working spinner row is redundant noise
354
+ // directly above it.
355
+ ctx.ui.setWorkingVisible(false);
356
+ startTicker();
357
+ // The SSE subscription opens once the completion's request id is known
358
+ // (header hook or first stream chunk); it is always scoped to that id.
359
+ });
360
+
361
+ // Assign each completion's request id up front: vLLM derives its request
362
+ // id from the X-Request-Id header (chatcmpl-<header>), so setting the
363
+ // header lets this turn correlate side-channel canvas events from the very
364
+ // first denoising step, before the server streams any completion chunk.
365
+ pi.on("before_provider_headers", (event) => {
366
+ const state = current;
367
+ if (state === undefined || state.done || eventsUrl === null) {
368
+ return;
369
+ }
370
+ const requestTag = crypto.randomUUID().replace(/-/gu, "");
371
+ event.headers["X-Request-Id"] = requestTag;
372
+ state.responseId = `chatcmpl-${requestTag}`;
373
+ resetLiveCorrelation(state);
374
+ openEventStream(state.responseId);
375
+ refreshLiveCanvas(state);
376
+ });
377
+
378
+ pi.on("message_update", (event, ctx) => {
379
+ const state = current;
380
+ if (ctx.mode !== "tui" || state === undefined || state.done) {
381
+ return;
382
+ }
383
+ // A turn can contain several completions (one per tool-call round), each
384
+ // with its own server request id. The id was predicted when the
385
+ // X-Request-Id header was set; the stream's partial.responseId is the
386
+ // server-authoritative value and corrects the prediction on servers that
387
+ // ignore the header.
388
+ const responseId = responseIdFromEvent(event.assistantMessageEvent);
389
+ if (responseId !== undefined && responseId !== state.responseId) {
390
+ state.responseId = responseId;
391
+ resetLiveCorrelation(state);
392
+ openEventStream(responseId);
393
+ refreshLiveCanvas(state);
394
+ }
395
+ const added = deltaFromEvent(event.assistantMessageEvent);
396
+ if (added === undefined || added.text.length === 0) {
397
+ return;
398
+ }
399
+ recordBurst(state, added.text, added.display);
400
+ requestRender();
401
+ });
402
+
403
+ pi.on("turn_end", (_event, ctx) => {
404
+ const state = current;
405
+ if (ctx.mode !== "tui" || state === undefined) {
406
+ return;
407
+ }
408
+ finishTurn(state);
409
+ closeEventStream();
410
+ // Wait for the turn-start sample: on short turns it can still be in
411
+ // flight when the turn ends.
412
+ const beforePromise = countersAtTurnStart;
413
+ void Promise.all([beforePromise, fetchCounters()]).then(([before, after]) => {
414
+ if (current !== state) {
415
+ return;
416
+ }
417
+ if (before !== undefined && after !== undefined) {
418
+ stepsPerCanvas = stepsPerCanvasFromDelta(before, after) ?? stepsPerCanvas;
419
+ }
420
+ requestRender();
421
+ });
422
+ requestRender();
423
+ });
424
+
425
+ pi.on("session_shutdown", (_event, ctx) => {
426
+ stopTicker();
427
+ closeEventStream();
428
+ current = undefined;
429
+ if (widgetInstalled && ctx.hasUI) {
430
+ ctx.ui.setWidget(widgetId, undefined);
431
+ ctx.ui.setWorkingVisible(true);
432
+ }
433
+ widgetInstalled = false;
434
+ });
435
+ }
436
+
437
+ function newTurnState(): TurnState {
438
+ return {
439
+ startedAt: Date.now(),
440
+ firstBurstAt: undefined,
441
+ lastBurstAt: undefined,
442
+ burstCount: 0,
443
+ totalChars: 0,
444
+ settledText: "",
445
+ active: [],
446
+ intervals: [],
447
+ liveMode: false,
448
+ rowsHighWater: maxRows,
449
+ responseId: undefined,
450
+ latestCanvasByRequest: new Map(),
451
+ liveText: undefined,
452
+ liveStep: 0,
453
+ liveBlock: undefined,
454
+ stepFloor: 0,
455
+ blockFloor: undefined,
456
+ suppressCanvasUntil: 0,
457
+ flashUntil: 0,
458
+ flashChars: 0,
459
+ done: false
460
+ };
461
+ }
462
+
463
+ // A commit ends its block, but the block's canvas events can still be in
464
+ // flight (the SSE feed and the completion stream are separate connections);
465
+ // rendering one after the commit shows the committed text twice. The block
466
+ // ordinal identifies such snapshots exactly. Servers without it fall back to
467
+ // the per-request step counter (a replayed step is always stale) plus a
468
+ // short post-commit grace window absorbing cross-connection reordering.
469
+ function isStaleCanvas(state: TurnState, event: CanvasEvent): boolean {
470
+ if (event.block !== undefined) {
471
+ return state.blockFloor !== undefined && event.block <= state.blockFloor;
472
+ }
473
+ if (event.step <= state.stepFloor) {
474
+ return true;
475
+ }
476
+ return Date.now() < state.suppressCanvasUntil;
477
+ }
478
+
479
+ // A new completion is a new server request whose step counter and block
480
+ // ordinals restart from zero; the previous completion's staleness floors
481
+ // must not swallow its first canvas events.
482
+ function resetLiveCorrelation(state: TurnState): void {
483
+ state.liveStep = 0;
484
+ state.liveBlock = undefined;
485
+ state.stepFloor = 0;
486
+ state.blockFloor = undefined;
487
+ state.suppressCanvasUntil = 0;
488
+ }
489
+
490
+ function recordBurst(state: TurnState, added: string, display: boolean): void {
491
+ const now = Date.now();
492
+ state.firstBurstAt ??= now;
493
+ if (state.lastBurstAt !== undefined) {
494
+ state.intervals.push(now - state.lastBurstAt);
495
+ }
496
+ state.lastBurstAt = now;
497
+ state.burstCount += 1;
498
+ state.totalChars += added.length;
499
+ // This commit ends the block being denoised: raise the staleness floors so
500
+ // the block's in-flight canvas events cannot re-render after the settled
501
+ // text (see isStaleCanvas).
502
+ state.stepFloor = state.liveStep;
503
+ state.blockFloor = state.liveBlock;
504
+ state.suppressCanvasUntil = now + commitGraceMs;
505
+ if (state.liveMode) {
506
+ // Truthful mode: the emergence was already shown live via the real
507
+ // canvas states. The committed text is the converged canvas the server
508
+ // never streams (commit steps emit no event), so render it bright for a
509
+ // moment before it mutes into the settled document. The next denoising
510
+ // step brings the next block.
511
+ if (display) {
512
+ const settled = sanitize(added);
513
+ state.settledText += settled;
514
+ state.flashChars = [...settled].length;
515
+ state.flashUntil = now + flashMs;
516
+ }
517
+ state.liveText = undefined;
518
+ return;
519
+ }
520
+ state.settledText += state.active.map((cell) => cell.char).join("");
521
+ if (!display) {
522
+ // Tool-call commits pace the stats but their JSON is not settled into
523
+ // the display text; Pi renders the tool call itself.
524
+ state.active = [];
525
+ return;
526
+ }
527
+ const resolveMs = resolveDuration(state);
528
+ const chars = [...sanitize(added)];
529
+ state.active = chars.map((char) => ({
530
+ char,
531
+ resolveAt: now + Math.random() * resolveMs
532
+ }));
533
+ }
534
+
535
+ // Keep the in-flight resolve animation on turn end; the last canvas would
536
+ // otherwise snap to text instantly because bursty servers deliver the final
537
+ // commit and the turn end back to back.
538
+ function finishTurn(state: TurnState): void {
539
+ state.done = true;
540
+ state.liveText = undefined;
541
+ }
542
+
543
+ function animationDone(state: TurnState): boolean {
544
+ const now = Date.now();
545
+ return now >= state.flashUntil && state.active.every((cell) => now >= cell.resolveAt);
546
+ }
547
+
548
+ function resolveDuration(state: TurnState): number {
549
+ const interval = medianOf(state.intervals);
550
+ if (interval === undefined) {
551
+ return defaultResolveMs;
552
+ }
553
+ return Math.min(Math.max(interval * 0.6, minResolveMs), maxResolveMs);
554
+ }
555
+
556
+ function medianOf(values: readonly number[]): number | undefined {
557
+ if (values.length === 0) {
558
+ return undefined;
559
+ }
560
+ const sorted = [...values].sort((a, b) => a - b);
561
+ return sorted[Math.floor(sorted.length / 2)];
562
+ }
563
+
564
+ // The canvas mixes scripts freely mid-denoise (multilingual renoise tokens).
565
+ // Terminals disagree with any width model for combining marks, Indic
566
+ // conjuncts, emoji sequences, and bidi-reordered RTL runs; a single
567
+ // mispredicted cell hard-wraps a row and desyncs the TUI's differential
568
+ // renderer, leaving stale frames in the transcript. Render only glyphs with
569
+ // unambiguous monospace width and substitute the rest with a neutral dot:
570
+ // they read as noise either way, and the real text lands in the message.
571
+ const substituteGlyph = "\u00b7";
572
+
573
+ function displayableChar(char: string): string {
574
+ const cp = char.codePointAt(0) ?? 0;
575
+ if (
576
+ (cp >= 0x20 && cp <= 0x7e) ||
577
+ (cp >= 0xa1 && cp <= 0x17f) ||
578
+ (cp >= 0x370 && cp <= 0x3ff && cp !== 0x374 && cp !== 0x375) ||
579
+ (cp >= 0x400 && cp <= 0x4ff) ||
580
+ (cp >= 0x2010 && cp <= 0x2027) ||
581
+ cp === 0x2591 ||
582
+ isWideChar(cp)
583
+ ) {
584
+ return char;
585
+ }
586
+ return substituteGlyph;
587
+ }
588
+
589
+ function sanitize(text: string): string {
590
+ const flattened = text.replace(/\s+/gu, " ").replace(/\p{C}/gu, "");
591
+ let result = "";
592
+ for (const char of flattened) {
593
+ result += displayableChar(char);
594
+ }
595
+ return result;
596
+ }
597
+
598
+ // Unambiguously two-cell scripts in every monospace terminal: CJK
599
+ // punctuation and kana, CJK unified ideographs, Hangul syllables, CJK
600
+ // compatibility ideographs, and fullwidth forms.
601
+ function isWideChar(cp: number): boolean {
602
+ return (
603
+ (cp >= 0x3000 && cp <= 0x30ff) ||
604
+ (cp >= 0x3400 && cp <= 0x4dbf) ||
605
+ (cp >= 0x4e00 && cp <= 0x9fff) ||
606
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
607
+ (cp >= 0xf900 && cp <= 0xfaff) ||
608
+ (cp >= 0xff01 && cp <= 0xff60)
609
+ );
610
+ }
611
+
612
+ function charWidth(char: string): number {
613
+ return isWideChar(char.codePointAt(0) ?? 0) ? 2 : 1;
614
+ }
615
+
616
+ function textWidth(text: string): number {
617
+ let width = 0;
618
+ for (const char of text) {
619
+ width += charWidth(char);
620
+ }
621
+ return width;
622
+ }
623
+
624
+ function cellsFromText(text: string, kind: RenderCellKind): RenderCell[] {
625
+ return [...text].map((char) => ({ char, kind, width: charWidth(char) }));
626
+ }
627
+
628
+ function tailByWidth(text: string, maxWidth: number): string {
629
+ const chars = [...text];
630
+ let width = 0;
631
+ let start = chars.length;
632
+ for (let index = chars.length - 1; index >= 0; index -= 1) {
633
+ const cw = charWidth(chars[index] ?? "");
634
+ if (width + cw > maxWidth) {
635
+ break;
636
+ }
637
+ width += cw;
638
+ start = index;
639
+ }
640
+ return chars.slice(start).join("");
641
+ }
642
+
643
+ function canvasRows(state: TurnState, cols: number, theme: WidgetTheme): string[] {
644
+ const rowCap = state.liveMode ? liveMaxRows : maxRows;
645
+ const cells = renderCells(state, cols * rowCap);
646
+ const rows: RenderCell[][] = [];
647
+ let row: RenderCell[] = [];
648
+ let rowWidth = 0;
649
+ let seamRow = 0;
650
+ for (const cell of cells) {
651
+ if (rowWidth + cell.width > cols) {
652
+ rows.push(row);
653
+ row = [];
654
+ rowWidth = 0;
655
+ }
656
+ row.push(cell);
657
+ rowWidth += cell.width;
658
+ if (cell.kind === "settled" || cell.kind === "resolved") {
659
+ seamRow = rows.length;
660
+ }
661
+ }
662
+ if (row.length > 0) {
663
+ rows.push(row);
664
+ }
665
+ // Live mode packs the whole settled+canvas document from its start, so row
666
+ // boundaries of already-committed text never move. The window anchors to
667
+ // the seam (the row where the settled text ends): a fixed amount of
668
+ // settled context stays above it and the canvas fills the rest. The
669
+ // canvas's decoded width fluctuates on every denoising step, so an
670
+ // end-anchored window would shift all visible rows each step; anchored to
671
+ // the seam, the committed text holds still and the view scrolls only when
672
+ // a commit moves the seam (like a terminal). Simulated mode builds a
673
+ // budget-sized window, so its rows are the head.
674
+ const windowStart = state.liveMode ? Math.max(0, seamRow - liveSettledContextRows) : 0;
675
+ const visible = rows.slice(windowStart, windowStart + rowCap);
676
+ return visible.map((cellsRow) => styleRow(cellsRow, theme));
677
+ }
678
+
679
+ function renderCells(state: TurnState, budget: number): RenderCell[] {
680
+ if (state.liveMode) {
681
+ return renderLiveCells(state);
682
+ }
683
+ return renderSimulatedCells(state, budget);
684
+ }
685
+
686
+ // Live mode: one continuous document, the committed text concatenated with
687
+ // the canvas snapshot currently being denoised. It is never clipped here;
688
+ // canvasRows packs it from the start (stable row boundaries for committed
689
+ // text) and anchors its window to the seam, so on a commit the resolved text
690
+ // stays in place and the next canvas continues mid-row without a gap. The
691
+ // freshest commit renders bright until its flash expires.
692
+ function renderLiveCells(state: TurnState): RenderCell[] {
693
+ const canvas = state.liveText === undefined ? "" : sanitize(state.liveText);
694
+ const settled = [...state.settledText];
695
+ const flashCount =
696
+ Date.now() < state.flashUntil ? Math.min(state.flashChars, settled.length) : 0;
697
+ const stable = settled.slice(0, settled.length - flashCount).join("");
698
+ const flashing = settled.slice(settled.length - flashCount).join("");
699
+ return [
700
+ ...cellsFromText(stable, "settled"),
701
+ ...cellsFromText(flashing, "resolved"),
702
+ ...cellsFromText(canvas, "noise")
703
+ ];
704
+ }
705
+
706
+ // Simulated mode: while the turn runs, glyph noise fills all window space not
707
+ // yet holding text (the canvas still being denoised server-side), and commit
708
+ // bursts resolve from noise into the real text. Budgets are display-width
709
+ // cells; noise glyphs are all width 1.
710
+ function renderSimulatedCells(state: TurnState, budget: number): RenderCell[] {
711
+ const now = Date.now();
712
+ const settledWidth = textWidth(state.settledText);
713
+ const activeWidth = state.active.reduce((width, cell) => width + charWidth(cell.char), 0);
714
+ const aheadCount = state.done
715
+ ? 0
716
+ : Math.min(Math.max(budget - settledWidth - activeWidth, Math.ceil(budget / maxRows)), budget);
717
+ const settledBudget = Math.max(budget - activeWidth - aheadCount, 0);
718
+ const cells: RenderCell[] = [
719
+ ...cellsFromText(tailByWidth(state.settledText, settledBudget), "settled")
720
+ ];
721
+ for (const cell of state.active) {
722
+ cells.push(
723
+ now >= cell.resolveAt
724
+ ? { char: cell.char, kind: "resolved", width: charWidth(cell.char) }
725
+ : { char: noiseChar(), kind: "noise", width: 1 }
726
+ );
727
+ }
728
+ for (let index = 0; index < aheadCount; index += 1) {
729
+ cells.push({ char: noiseChar(), kind: "ahead", width: 1 });
730
+ }
731
+ let total = cells.reduce((width, cell) => width + cell.width, 0);
732
+ while (total > budget && cells.length > 0) {
733
+ total -= cells[0]?.width ?? 0;
734
+ cells.shift();
735
+ }
736
+ return cells;
737
+ }
738
+
739
+ function styleRow(cells: readonly RenderCell[], theme: WidgetTheme): string {
740
+ let row = "";
741
+ let runKind: RenderCellKind | undefined;
742
+ let runText = "";
743
+ const flush = (): void => {
744
+ if (runKind !== undefined && runText.length > 0) {
745
+ row += styleRun(runKind, runText, theme);
746
+ }
747
+ runText = "";
748
+ };
749
+ for (const cell of cells) {
750
+ if (cell.kind !== runKind) {
751
+ flush();
752
+ runKind = cell.kind;
753
+ }
754
+ runText += cell.char;
755
+ }
756
+ flush();
757
+ return row;
758
+ }
759
+
760
+ function styleRun(kind: RenderCellKind, text: string, theme: WidgetTheme): string {
761
+ switch (kind) {
762
+ case "settled":
763
+ return theme.fg("muted", text);
764
+ case "resolved":
765
+ return theme.fg("text", text);
766
+ case "noise":
767
+ return theme.fg("accent", text);
768
+ case "ahead":
769
+ return theme.fg("dim", text);
770
+ }
771
+ }
772
+
773
+ function noiseChar(): string {
774
+ return noiseGlyphs[Math.floor(Math.random() * noiseGlyphs.length)] ?? "?";
775
+ }
776
+
777
+ function truncate(text: string, maxLength: number): string {
778
+ return text.length <= maxLength ? text : `${text.slice(0, Math.max(maxLength - 1, 0))}…`;
779
+ }
780
+
781
+ async function consumeEventStream(
782
+ url: string,
783
+ signal: AbortSignal,
784
+ onEvent: (event: CanvasEvent) => void
785
+ ): Promise<void> {
786
+ const response = await fetch(url, { signal });
787
+ if (!response.ok || response.body === null) {
788
+ throw new Error(`diffusion events unavailable: ${String(response.status)}`);
789
+ }
790
+ const reader = (response.body as ReadableStream<Uint8Array>).getReader();
791
+ // fetch rejects reads on abort, but cancel explicitly so the stream is
792
+ // released even when a runtime (or test double) ignores the signal.
793
+ signal.addEventListener("abort", () => void reader.cancel().catch(() => undefined), {
794
+ once: true
795
+ });
796
+ const decoder = new TextDecoder();
797
+ let buffer = "";
798
+ for (;;) {
799
+ const { done, value } = await reader.read();
800
+ if (done || signal.aborted) {
801
+ return;
802
+ }
803
+ // SSE allows LF, CRLF, and CR line endings; normalize to LF so frame
804
+ // splitting works for all conforming servers.
805
+ buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/gu, "\n");
806
+ for (;;) {
807
+ const boundary = buffer.indexOf("\n\n");
808
+ if (boundary === -1) {
809
+ break;
810
+ }
811
+ const frame = buffer.slice(0, boundary);
812
+ buffer = buffer.slice(boundary + 2);
813
+ const event = parseEventFrame(frame);
814
+ if (event !== undefined) {
815
+ onEvent(event);
816
+ }
817
+ }
818
+ }
819
+ }
820
+
821
+ function parseEventFrame(frame: string): CanvasEvent | undefined {
822
+ const dataLines = frame
823
+ .split("\n")
824
+ .filter((line) => line.startsWith("data:"))
825
+ .map((line) => line.slice(5).trim());
826
+ if (dataLines.length === 0) {
827
+ return undefined;
828
+ }
829
+ try {
830
+ const parsed: unknown = JSON.parse(dataLines.join("\n"));
831
+ if (parsed === null || typeof parsed !== "object") {
832
+ return undefined;
833
+ }
834
+ const object = parsed as Record<string, unknown>;
835
+ const requestId = object["request_id"];
836
+ const step = object["step"];
837
+ const text = object["text"];
838
+ const block = object["block"];
839
+ if (typeof requestId !== "string" || typeof step !== "number" || typeof text !== "string") {
840
+ return undefined;
841
+ }
842
+ return { requestId, step, text, block: typeof block === "number" ? block : undefined };
843
+ } catch {
844
+ return undefined;
845
+ }
846
+ }
847
+
848
+ async function fetchCounters(): Promise<DiffusionCounters | undefined> {
849
+ if (metricsUrl === null) {
850
+ return undefined;
851
+ }
852
+ try {
853
+ // Bound the request so a stalled /metrics endpoint cannot accumulate
854
+ // pending sockets across turns; a missed sample only hides the
855
+ // steps/canvas stat.
856
+ const response = await fetch(metricsUrl, { signal: AbortSignal.timeout(metricsTimeoutMs) });
857
+ if (!response.ok) {
858
+ return undefined;
859
+ }
860
+ return parseDiffusionCounters(await response.text());
861
+ } catch {
862
+ return undefined;
863
+ }
864
+ }
865
+
866
+ function parseDiffusionCounters(body: string): DiffusionCounters | undefined {
867
+ let steps: number | undefined;
868
+ let positions: number | undefined;
869
+ let committed: number | undefined;
870
+ let found = false;
871
+ for (const line of body.split("\n")) {
872
+ if (line.startsWith("#")) {
873
+ continue;
874
+ }
875
+ const match = /^vllm:diffusion_(\w+?)(?:_total)?(?:\{[^}]*\})? (\S+)$/u.exec(line.trim());
876
+ if (match === null) {
877
+ continue;
878
+ }
879
+ const value = Number(match[2]);
880
+ if (!Number.isFinite(value)) {
881
+ continue;
882
+ }
883
+ if (match[1] === "num_denoising_steps") {
884
+ steps = (steps ?? 0) + value;
885
+ found = true;
886
+ } else if (match[1] === "num_canvas_positions") {
887
+ positions = (positions ?? 0) + value;
888
+ found = true;
889
+ } else if (match[1] === "num_committed_tokens") {
890
+ committed = (committed ?? 0) + value;
891
+ found = true;
892
+ }
893
+ }
894
+ if (!found || steps === undefined || positions === undefined || committed === undefined) {
895
+ return undefined;
896
+ }
897
+ return { steps, positions, committed };
898
+ }
899
+
900
+ function stepsPerCanvasFromDelta(
901
+ before: DiffusionCounters,
902
+ after: DiffusionCounters
903
+ ): number | undefined {
904
+ const steps = after.steps - before.steps;
905
+ const positions = after.positions - before.positions;
906
+ const committed = after.committed - before.committed;
907
+ if (steps <= 0 || positions <= 0 || committed <= 0) {
908
+ return undefined;
909
+ }
910
+ const canvasLength = positions / steps;
911
+ const canvases = committed / canvasLength;
912
+ const denoiseSteps = steps - canvases;
913
+ if (canvases <= 0 || denoiseSteps <= 0) {
914
+ return undefined;
915
+ }
916
+ return denoiseSteps / canvases;
917
+ }
918
+
919
+ type CommitDelta = {
920
+ readonly text: string;
921
+ // Whether the delta belongs in the settled display text. Tool-call JSON
922
+ // paces the commit stats but is rendered by Pi's own tool widgets.
923
+ readonly display: boolean;
924
+ };
925
+
926
+ // Pi's assistant stream events carry the in-progress message as "partial"
927
+ // (or the final one as "message"), whose responseId is the server-side
928
+ // request id (chatcmpl-...). That id is what the diffusion events side
929
+ // channel reports, so it correlates live canvas events with this turn.
930
+ function responseIdFromEvent(value: unknown): string | undefined {
931
+ if (value === null || typeof value !== "object") {
932
+ return undefined;
933
+ }
934
+ const object = value as Record<string, unknown>;
935
+ const partial = object["partial"] ?? object["message"];
936
+ if (partial === null || typeof partial !== "object") {
937
+ return undefined;
938
+ }
939
+ const responseId = (partial as Record<string, unknown>)["responseId"];
940
+ return typeof responseId === "string" ? responseId : undefined;
941
+ }
942
+
943
+ // Every streamed delta is a canvas commit on a diffusion server, including
944
+ // thinking (DiffusionGemma routes its whole output through the reasoning
945
+ // field until an explicit end marker), so all delta kinds count toward the
946
+ // stats. Non-delta events (start/end/done markers) carry no new tokens.
947
+ function deltaFromEvent(value: unknown): CommitDelta | undefined {
948
+ if (value === null || typeof value !== "object") {
949
+ return undefined;
950
+ }
951
+ const object = value as Record<string, unknown>;
952
+ const type = object["type"];
953
+ const delta = object["delta"];
954
+ if (typeof type !== "string" || typeof delta !== "string") {
955
+ return undefined;
956
+ }
957
+ if (type === "text_delta" || type === "thinking_delta") {
958
+ return { text: delta, display: true };
959
+ }
960
+ if (type === "toolcall_delta") {
961
+ return { text: delta, display: false };
962
+ }
963
+ return undefined;
964
+ }