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,159 @@
1
+ // diffusionpi hack: smooth viewport scroll.
2
+ //
3
+ // Pi's TUI renders the whole chat in one frame and its differential renderer
4
+ // appends every new line at once, so a diffusion commit (~a whole canvas,
5
+ // often 15+ lines) shoves the viewport up in a single jump. There is no
6
+ // public extension API over the chat container or the viewport, so this
7
+ // extension deliberately reaches into the live TUI instance (grabbed through
8
+ // a throwaway widget factory) and wraps the chat container's render() to
9
+ // reveal appended lines at a bounded rate instead of all at once. Real
10
+ // content, paced only at the render layer.
11
+ //
12
+ // This is version-sensitive by design: it checks that the component tree
13
+ // looks like Pi 0.8x (nine TUI children, chat at index 2) and no-ops
14
+ // otherwise, so a Pi upgrade degrades to the stock jumpy behavior rather
15
+ // than breaking.
16
+ //
17
+ // Tuning:
18
+ // DIFFUSIONPI_SMOOTH_SCROLL=0 disable the hack
19
+ // DIFFUSIONPI_SCROLL_SPEED=40 reveal rate in lines per second
20
+ // DIFFUSIONPI_SCROLL_DEBUG=<path> append per-frame pacing decisions to a file
21
+ import { appendFileSync } from "node:fs";
22
+
23
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
24
+
25
+ type RenderComponent = {
26
+ render(width: number): string[];
27
+ invalidate?(): void;
28
+ };
29
+
30
+ type TuiLike = {
31
+ children: RenderComponent[];
32
+ requestRender(force?: boolean): void;
33
+ };
34
+
35
+ type WidgetUi = {
36
+ setWidget(
37
+ key: string,
38
+ content: ((tui: TuiLike, theme: unknown) => RenderComponent) | undefined
39
+ ): void;
40
+ };
41
+
42
+ const enabled = process.env["DIFFUSIONPI_SMOOTH_SCROLL"] !== "0";
43
+ const linesPerSecond = positiveNumber(process.env["DIFFUSIONPI_SCROLL_SPEED"], 40);
44
+ const debugPath = process.env["DIFFUSIONPI_SCROLL_DEBUG"];
45
+
46
+ function debugLog(message: string): void {
47
+ if (debugPath === undefined || debugPath === "") {
48
+ return;
49
+ }
50
+ try {
51
+ appendFileSync(debugPath, `${String(Date.now())} ${message}\n`);
52
+ } catch {
53
+ // Debug logging must never break rendering.
54
+ }
55
+ }
56
+ // A growth this large is a session restore or theme redraw, not streaming:
57
+ // show it instantly instead of animating through it.
58
+ const snapThresholdLines = 300;
59
+ // Upper bound on the time credited between renders, so the first paced frame
60
+ // after an idle period does not instantly spend seconds of accumulated budget.
61
+ const maxTickSeconds = 0.1;
62
+ const catchUpDelayMs = 33;
63
+ const expectedTuiChildren = 9;
64
+ const chatChildIndex = 2;
65
+
66
+ function positiveNumber(raw: string | undefined, fallback: number): number {
67
+ const value = Number(raw);
68
+ return Number.isFinite(value) && value > 0 ? value : fallback;
69
+ }
70
+
71
+ export default function diffusionpiSmoothScroll(pi: ExtensionAPI): void {
72
+ if (!enabled) {
73
+ return;
74
+ }
75
+ let patched = false;
76
+ pi.on("session_start", (_event, ctx) => {
77
+ if (ctx.mode !== "tui" || patched) {
78
+ return;
79
+ }
80
+ patched = tryPatch(ctx);
81
+ });
82
+ }
83
+
84
+ // The widget factory is invoked synchronously with the live TUI instance;
85
+ // registering and immediately removing a probe widget is the only way an
86
+ // extension can get its hands on the TUI outside a dialog.
87
+ function tryPatch(ctx: ExtensionContext): boolean {
88
+ let tui: TuiLike | undefined;
89
+ const ui = ctx.ui as unknown as WidgetUi;
90
+ if (typeof ui.setWidget !== "function") {
91
+ return false;
92
+ }
93
+ ui.setWidget("diffusionpi-smooth-scroll-probe", (liveTui) => {
94
+ tui = liveTui;
95
+ return { render: () => [], invalidate: () => undefined };
96
+ });
97
+ ui.setWidget("diffusionpi-smooth-scroll-probe", undefined);
98
+ if (tui === undefined || !Array.isArray(tui.children)) {
99
+ return false;
100
+ }
101
+ if (tui.children.length !== expectedTuiChildren) {
102
+ return false;
103
+ }
104
+ const chat = tui.children[chatChildIndex];
105
+ if (chat === undefined || typeof chat.render !== "function") {
106
+ return false;
107
+ }
108
+ installPacer(tui, chat);
109
+ debugLog("pacer installed");
110
+ return true;
111
+ }
112
+
113
+ function installPacer(tui: TuiLike, chat: RenderComponent): void {
114
+ const original = chat.render.bind(chat);
115
+ let revealed = Number.POSITIVE_INFINITY; // first render snaps to full
116
+ let lastWidth = -1;
117
+ let lastTickAt = 0;
118
+ let carry = 0;
119
+ let timer: ReturnType<typeof setTimeout> | undefined;
120
+
121
+ const scheduleCatchUp = (): void => {
122
+ if (timer !== undefined) {
123
+ return;
124
+ }
125
+ timer = setTimeout(() => {
126
+ timer = undefined;
127
+ tui.requestRender();
128
+ }, catchUpDelayMs);
129
+ };
130
+
131
+ chat.render = (width: number): string[] => {
132
+ const lines = original(width);
133
+ const now = Date.now();
134
+ if (width !== lastWidth) {
135
+ // Rewrap after a resize changes every line count; animating the reflow
136
+ // would look like scrolling through the whole session.
137
+ lastWidth = width;
138
+ revealed = lines.length;
139
+ return lines;
140
+ }
141
+ if (lines.length <= revealed || lines.length - revealed > snapThresholdLines) {
142
+ revealed = lines.length;
143
+ lastTickAt = now;
144
+ carry = 0;
145
+ return lines;
146
+ }
147
+ const dt = Math.min(maxTickSeconds, Math.max(0, (now - lastTickAt) / 1000));
148
+ lastTickAt = now;
149
+ carry += linesPerSecond * dt;
150
+ const step = Math.floor(carry);
151
+ carry -= step;
152
+ revealed = Math.min(lines.length, revealed + step);
153
+ debugLog(`pacing revealed=${String(revealed)} total=${String(lines.length)} step=${String(step)}`);
154
+ if (revealed < lines.length) {
155
+ scheduleCatchUp();
156
+ }
157
+ return lines.slice(0, revealed);
158
+ };
159
+ }
@@ -0,0 +1,31 @@
1
+ id = "diffusionpi"
2
+ name = "DiffusionPi"
3
+ version = "0.1.0"
4
+ schema_version = 1
5
+ description = "Pi + DiffusionGemma with a live diffusion canvas"
6
+ state_dir = "~/.local/state/diffusionpi"
7
+ # Stock Pi via pi-factory's default pi_command (npx @earendil-works/pi-coding-agent).
8
+ thinking = "medium"
9
+
10
+ [provider]
11
+ id = "vllm"
12
+ base_url = "http://127.0.0.1:8000/v1"
13
+ api = "openai-completions"
14
+
15
+ [model]
16
+ id = "nvidia/diffusiongemma-26B-A4B-it-NVFP4"
17
+ name = "DiffusionGemma 26B-A4B NVFP4"
18
+ context_window = 32768
19
+ max_tokens = 8192
20
+ reasoning = true
21
+
22
+ [[extensions]]
23
+ path = "extensions/diffusion-canvas.ts"
24
+
25
+ # Shared self-driving demo extension; inert unless PI_DEMO_MODE=1 (the demo
26
+ # manifest sets it), so it is safe to keep loaded in interactive sessions.
27
+ [[extensions]]
28
+ path = "../node_modules/pi-demo-mode/extensions/demo-mode.ts"
29
+
30
+ [[extensions]]
31
+ path = "extensions/smooth-scroll.ts"
@@ -0,0 +1 @@
1
+ Continue. Try to write as long as possible.
@@ -0,0 +1 @@
1
+ You are narrating a never-ending sci-fi adventure. Continue in short paragraphs. Whenever the user sends a message, treat it as a live director note and incorporate it immediately. Never end the story.
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bash
2
+ # Launches the diffusionpi Pi app bundle via pi-factory (stock Pi, no fork).
3
+ # Subcommands: run (default), demo, plan, validate.
4
+ set -euo pipefail
5
+
6
+ root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
7
+ app_dir="$root/app"
8
+
9
+ pi_factory() {
10
+ if command -v pi-factory >/dev/null 2>&1; then
11
+ pi-factory "$@"
12
+ else
13
+ npx -y @dutifuldev/pi-factory@latest "$@"
14
+ fi
15
+ }
16
+
17
+ # Shared Pi extension packages (pi-demo-mode) are npm dependencies of the
18
+ # bundle; fetch them on first run so the TOML extension paths resolve.
19
+ ensure_deps() {
20
+ if [ ! -d "$root/node_modules/pi-demo-mode" ]; then
21
+ npm install --prefix "$root" --no-fund --no-audit >&2
22
+ fi
23
+ }
24
+
25
+ ensure_deps
26
+
27
+ case "${1:-run}" in
28
+ run) pi_factory run --app-dir "$app_dir" ;;
29
+ demo)
30
+ PI_DEMO_INITIAL_PROMPT_FILE="$app_dir/prompts/demo-initial.md" \
31
+ PI_DEMO_FOLLOWUP_PROMPT_FILE="$app_dir/prompts/demo-followup.md" \
32
+ pi_factory run --app-file "$app_dir/demo.pi-factory.toml"
33
+ ;;
34
+ plan) pi_factory plan --app-dir "$app_dir" ;;
35
+ validate) pi_factory validate "$app_dir" ;;
36
+ *)
37
+ echo "usage: diffusionpi [run|demo|plan|validate]" >&2
38
+ exit 2
39
+ ;;
40
+ esac
@@ -0,0 +1,104 @@
1
+ # Reproducing the live diffusion canvas
2
+
3
+ This documents every piece needed to reproduce the truthful diffusion canvas
4
+ visualization: DiffusionGemma served by a patched vLLM that streams its
5
+ intermediate denoising states, rendered live in the Pi TUI.
6
+
7
+ ## The pieces
8
+
9
+ | Piece | Where it lives | What it does |
10
+ | --------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
11
+ | vLLM canvas streaming | [`osolmaz/vllm`](https://github.com/osolmaz/vllm), branch `diffusion-canvas-events`, release tag `canvas-v0.23.1rc4` | Adds the opt-in `--diffusion-stream-canvas` server flag and the `GET /v1/diffusion/events` SSE side channel that emits the detokenized canvas on every denoising step. See the fork's [`DIFFUSION_CANVAS.md`](https://github.com/osolmaz/vllm/blob/diffusion-canvas-events/DIFFUSION_CANVAS.md). Tracked as [PR #1 on the fork](https://github.com/osolmaz/vllm/pull/1). |
12
+ | Pi widget | [`app/extensions/diffusion-canvas.ts`](../app/extensions/diffusion-canvas.ts) in this repo | Self-contained Pi extension that renders the canvas above the editor. Not published separately; this repo is its home. |
13
+ | diffusionpi app | [`app/`](../app/) bundle + [`bin/diffusionpi`](../bin/diffusionpi) launcher | pi-factory bundle that launches stock Pi with the widget against the local vLLM server. |
14
+
15
+ The vLLM changes are deliberately not upstreamed; they are maintained as
16
+ tagged fork releases (`canvas-v<upstream version>rc<N>`, PEP 440-parseable so
17
+ vLLM's version detection accepts them).
18
+
19
+ ## 1. Install the patched vLLM
20
+
21
+ All fork changes are pure Python, so the install overlays them on the
22
+ official precompiled kernels — no compilation:
23
+
24
+ ```bash
25
+ VLLM_USE_PRECOMPILED=1 \
26
+ VLLM_PRECOMPILED_WHEEL_COMMIT=4e5ca89cfe98121642d76b40e32a006f4d0fbf3b \
27
+ pip install git+https://github.com/osolmaz/vllm@canvas-v0.23.1rc4
28
+ ```
29
+
30
+ `VLLM_PRECOMPILED_WHEEL_COMMIT` pins the official wheel that provides the
31
+ binary artifacts. It must match the fork release's upstream base commit,
32
+ which is recorded in the fork's `DIFFUSION_CANVAS.md`.
33
+
34
+ ## 2. Serve DiffusionGemma with canvas streaming
35
+
36
+ ```bash
37
+ vllm serve nvidia/diffusiongemma-26B-A4B-it-NVFP4 \
38
+ --host 127.0.0.1 --port 8000 \
39
+ --max-model-len 32768 --max-num-seqs 16 --max-num-batched-tokens 8192 \
40
+ --kv-cache-dtype fp8 \
41
+ --enable-auto-tool-choice --tool-call-parser gemma4 \
42
+ --diffusion-stream-canvas
43
+ ```
44
+
45
+ `--diffusion-stream-canvas` requires the single-process FastAPI frontend (no
46
+ `--grpc`, headless mode, or external/hybrid/multi-port data-parallel load
47
+ balancing); the server rejects incompatible combinations at startup.
48
+
49
+ To sanity-check the side channel independently of any client:
50
+
51
+ ```bash
52
+ curl -N http://127.0.0.1:8000/v1/diffusion/events
53
+ ```
54
+
55
+ then run any chat completion against the server; per-step JSON events
56
+ (`request_id`, `step`, `block`, `text`) should stream out.
57
+
58
+ ## 3. Run the visualizer
59
+
60
+ Through the diffusionpi launcher (from the repo root):
61
+
62
+ ```bash
63
+ bin/diffusionpi # interactive session
64
+ bin/diffusionpi demo # self-driving story demo
65
+ ```
66
+
67
+ Or in plain Pi, load the self-contained extension file directly (copy it into
68
+ your Pi extensions directory or pass it with `--extension`):
69
+
70
+ ```bash
71
+ pi --extension ./diffusionpi/app/extensions/diffusion-canvas.ts
72
+ ```
73
+
74
+ The widget needs no configuration: it derives the events and metrics URLs
75
+ from the active model's `baseUrl`, with `PI_DIFFUSION_CANVAS_EVENTS_URL` and
76
+ `PI_DIFFUSION_CANVAS_METRICS_URL` as overrides. Against a server without the
77
+ side channel it falls back to a clearly labeled simulation paced by the real
78
+ commit bursts.
79
+
80
+ ## How the truthful path works
81
+
82
+ - Each denoising step, vLLM's diffusion sampler produces an `argmax_canvas`
83
+ (its current best guess for every canvas position). The fork carries these
84
+ through the draft-token path into `EngineCoreOutput`, detokenizes them in
85
+ the API server, and broadcasts them on `/v1/diffusion/events`.
86
+ - The Pi extension assigns each completion a request id up front via the
87
+ `X-Request-Id` header (vLLM derives the `chatcmpl-*` id from it), then
88
+ subscribes to the side channel scoped to that id
89
+ (`?request_id=...`). Uncorrelated canvases are never rendered, so a shared
90
+ server cannot leak another client's text into the TUI.
91
+ - The widget renders committed text and the resolving canvas as one
92
+ continuous document windowed at the settled/canvas seam, keeps a stable
93
+ height, discards canvas snapshots of already-committed blocks (the `block`
94
+ ordinal), and substitutes glyphs of ambiguous terminal width, so the TUI's
95
+ differential renderer never leaves stale frames in scrollback.
96
+
97
+ ## Upgrading the fork to a newer vLLM
98
+
99
+ 1. Rebase `diffusion-canvas-events` onto the new upstream base commit.
100
+ 2. Run the fork's test suite: `pytest tests/v1/engine/test_diffusion_events.py`.
101
+ 3. Update the base commit in the fork's `DIFFUSION_CANVAS.md` and in the
102
+ install one-liners here and in the package README.
103
+ 4. Tag the next `canvas-v<upstream version>rc<N>` and push the tag.
104
+ 5. Re-verify the install one-liner in a scratch venv and the live TUI flow.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "diffusionpi",
3
+ "version": "0.2.0",
4
+ "description": "Watch a diffusion LLM denoise its answer live in the Pi TUI",
5
+ "keywords": [
6
+ "pi",
7
+ "diffusion",
8
+ "vllm",
9
+ "tui"
10
+ ],
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/osolmaz/diffusionpi.git"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/osolmaz/diffusionpi/issues"
18
+ },
19
+ "homepage": "https://github.com/osolmaz/diffusionpi#readme",
20
+ "bin": {
21
+ "diffusionpi": "bin/diffusionpi"
22
+ },
23
+ "files": [
24
+ "bin/**",
25
+ "app/**",
26
+ "docs/**",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "dependencies": {
31
+ "pi-demo-mode": "github:osolmaz/pi-demo-mode#v0.1.0"
32
+ }
33
+ }