Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/diffr-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,11 @@ jobs:
run: |
pnpm build
git diff --exit-code -- plugin
- working-directory: diffr/packages/pi
run: |
pnpm typecheck
pnpm test
pnpm build
- working-directory: diffr/packages/tui
run: |
pnpm typecheck
Expand Down
19 changes: 19 additions & 0 deletions diffr/packages/pi/USAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Diffr for Pi

Tested with Pi 0.99.1 and 1.1.0. Use `diffr` on PATH and Pi's fullscreen terminal renderer for mouse input.
From the repository root, run `pnpm install`, then `pnpm --dir diffr/packages/pi build`.
Launch `pi/bin/diffr-pi.mjs` (or `pi --tui-mode fullscreen -e /absolute/path/to/pi/dist/index.js`).

- `/diffr [arguments]` opens a floating panel at the right. Arguments go directly to Diffr, without a shell.
- The model can call `diffr_open({args: [...]})`. It opens without taking keyboard focus and acknowledges a valid comparison start. Pi registers this tool through its native extension API; no separate MCP process is needed.
- F7 focuses or reopens the panel. Escape returns to chat; a fullscreen overlay hides until F7.
- F6 or the top bar toggles fullscreen. `/diffr-fullscreen` also opens or toggles it.
- Drag source lines, then Enter to append references and contents to the current draft. Nothing is submitted.
- `V` marks a file viewed, `zM` folds scopes, `zR` unfolds them, and `\\` toggles files.
- `q` in the pane or `/diffr-close` closes it. Session changes also close it.

The panel covers part of the chat; it does not resize the native transcript into a left column.
Pi's public `ctx.ui.custom()` overlay API owns placement and focus. No private layout fields are used.
The panel leaves the bottom quarter of the terminal available for the draft. Fullscreen shows only Diffr.
Viewed marks and folds survive view switches, but reopening starts a new comparison.
`--diffr-bin /path/to/diffr` selects a binary; `--diffr-input /path/to/recording.ndjson` opens a saved comparison.
6 changes: 6 additions & 0 deletions diffr/packages/pi/bin/diffr-pi.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
const child = spawn("pi", ["--tui-mode", "fullscreen", "-e", fileURLToPath(new URL("../dist/index.js", import.meta.url)), ...process.argv.slice(2)], { stdio: "inherit" });
child.on("error", error => { console.error(error.message); process.exitCode = 1; });
child.on("exit", (code, signal) => { process.exitCode = code ?? (signal ? 1 : 0); });
4 changes: 4 additions & 0 deletions diffr/packages/pi/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { build } from "esbuild";

await build({ entryPoints: ["src/index.ts"], outdir: "dist", bundle: true, platform: "node", format: "esm",
external: ["@earendil-works/pi-coding-agent", "@earendil-works/pi-tui", "typebox"] });
38 changes: 38 additions & 0 deletions diffr/packages/pi/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "@diffr/pi",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"pi": {
"extensions": [
"./dist/index.js"
]
},
"bin": {
"diffr-pi": "./bin/diffr-pi.mjs"
},
"scripts": {
"build": "node build.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@diffr/consumer": "workspace:*",
"@diffr/viewer": "workspace:*"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": ">=0.99.1 <2",
"@earendil-works/pi-tui": ">=0.99.1 <2",
"typebox": "*"
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "0.99.1",
"@earendil-works/pi-tui": "0.99.1",
"typescript": "5.9.3",
"typebox": "1.3.27",
"@types/node": "24.12.2",
"vitest": "4.1.11",
"esbuild": "0.28.1"
}
}
140 changes: 140 additions & 0 deletions diffr/packages/pi/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { expect, test } from "vitest";
import { stripVTControlCharacters } from "node:util";
import { fileURLToPath } from "node:url";
import type { TuiMouseEvent } from "@earendil-works/pi-tui";
import diffr from "./index";
import { host } from "../test/host";
import { terminalHost } from "../test/terminal-host";

function harness(h: ReturnType<typeof host> | ReturnType<typeof terminalHost> = host()) {
let tool: any;
const events = new Map<string, () => void>();
const ctx: any = { mode: "tui", cwd: process.cwd(), ui: h.ui };
diffr({ registerFlag() {}, registerCommand() {}, registerShortcut() {}, on(name: string, fn: () => void) { events.set(name, fn); },
registerTool(value: any) { tool = value; },
getFlag(name: string) { if (name === "diffr-input") return fileURLToPath(new URL("../../consumer/test/fixtures/scopes.ndjson", import.meta.url)); },
} as any);
return { ctx, events, input: h.input, get component() { return h.widget!; }, get tool() { return tool; },
get draft() { return h.ui.getEditorText(); }, get submitted() { return h.submitted; } };
}

test.each(["unified", "split"] as const)("Pi %s region selection preserves both sides across redraws and appends to an unsent draft", async layout => {
const h = harness();
const result = await h.tool.execute("call", { args: [] }, undefined, undefined, h.ctx);
expect(result.content[0].text).toContain("1 changed files");
h.input(" Still writing.");
expect(h.draft).toContain(" Still writing.");
h.input("\x1b[18~"); // F7 focuses the diff.
let lines = h.component.render(360).map(line => stripVTControlCharacters(line));
if (!lines.some(line => line.includes(`${layout} [s]`))) h.input("s");
lines = h.component.render(360).map(line => stripVTControlCharacters(line));
const start = lines.findIndex(line => line.includes("for (const plugin"));
const middle = lines.findIndex(line => line.includes("Number(plugin.lines)"));
const end = lines.findIndex(line => line.includes("plugin.enabled"));
expect(start).toBeGreaterThan(0);
const mouse = (type: TuiMouseEvent["type"], x: number, y: number) => {
h.component.handleMouse!({ type, x, y, screenX: x, screenY: y, width: 360, height: 30,
button: "left", shift: false, alt: false, ctrl: false });
h.component.render(360);
};
const left = lines[start]!.indexOf("for (const plugin");
const right = lines[end]!.lastIndexOf("plugin.enabled");
const before = h.draft;
mouse("press", left, start); mouse("drag", left, middle); mouse("drag", right, end); mouse("release", right, end);
h.input("\r");
expect(h.draft).toContain("Keep my draft.");
expect(h.draft).toContain("- plugin.lines = Number(plugin.lines);");
expect(h.draft).toContain("+ plugin.lines = Number(plugin.shape.lines);");
expect(h.draft).toContain(" plugin.enabled = true;");
const forward = h.draft;
h.component.render(360);
mouse("press", right, end); mouse("drag", right, middle); mouse("drag", left, start); mouse("release", left, start);
h.input("\r");
expect(h.draft.slice(forward.length)).toBe(forward.slice(before.length));
const draft = h.draft;
h.events.get("session_start")!();
expect(h.submitted).toBe(0);
expect(h.draft).toBe(draft);
});

test("headless tool calls report an unavailable UI instead of claiming success", async () => {
const h = harness();
h.ctx.mode = "rpc";
await expect(h.tool.execute("call", {}, undefined, undefined, h.ctx)).rejects.toThrow("interactive terminal");
expect(h.draft).toBe("Keep my draft.");
});

test("docked folding survives height changes and picker cancellation leaves the draft intact", async () => {
const h = harness();
await h.tool.execute("call", {}, undefined, undefined, h.ctx);
h.input("\x1b[18~");
const lines = () => h.component.render(180).map(line => stripVTControlCharacters(line));
expect(lines().some(line => line.includes("plugin.enabled"))).toBe(true);
h.input("z"); h.input("M");
expect(lines().some(line => line.includes("plugin.enabled"))).toBe(false);
h.input("\x1b[17~"); // F6 expands the same comparison.
expect(lines().some(line => line.includes("plugin.enabled"))).toBe(false);
h.input("z"); h.input("R");
expect(lines().some(line => line.includes("plugin.enabled"))).toBe(true);
const before = h.draft;
h.input("\x10"); h.input("\x03"); // file picker then cancel
expect(h.draft).toBe(before);
const rendered = lines();
const row = rendered.findIndex(line => line.includes("▌▾"));
const x = rendered[row]!.indexOf("▌▾") + 1;
h.component.handleMouse!({ type: "press", x, y: row, screenX: x, screenY: row,
width: 180, height: 40, button: "left", shift: false, alt: false, ctrl: false });
expect(lines().some(line => line.includes("plugin.enabled"))).toBe(false);
h.component.handleMouse!({ type: "press", x, y: row, screenX: x, screenY: row,
width: 180, height: 40, button: "left", shift: false, alt: false, ctrl: false });
expect(lines().some(line => line.includes("plugin.enabled"))).toBe(true);
expect(h.submitted).toBe(0);
h.events.get("session_start")!();
});


test("real Pi mouse routing returns typing to the draft and preserves caret clicks after selection", async () => {
const terminal = terminalHost();
const h = harness(terminal);
try {
await h.tool.execute("call", {}, undefined, undefined, h.ctx);
terminal.tui.renderNow();
const lines = () => h.component.render(180).map(line => stripVTControlCharacters(line));
const original = lines();
const row = original.findIndex(line => line.includes("▾") && line.includes("for (const plugin"));
expect(row).toBeGreaterThan(0);
const caret = original[row]!.indexOf("▾");
const code = original[row]!.indexOf("for (const plugin");
const bodyRow = original.findIndex(line => line.includes("Number(plugin.lines)"));
const rail = original[bodyRow]!.lastIndexOf("│");
terminal.mouse(35, rail + 1, bodyRow); // Motion without a pressed button.
expect(lines()[bodyRow]![rail]).toBe("┃");
terminal.mouse(35, code + 12, row);
expect(lines()[bodyRow]![rail]).toBe("│");
terminal.click(code, row);
const editorRow = lines().length + 1;
terminal.click(3, editorRow);
const before = h.draft;
for (const char of "typing") terminal.input(char);
expect(h.draft).not.toBe(before);
expect(h.draft).toContain("typing");
for (let i = 0; i < 4; i++) {
terminal.click(caret - 1, row);
expect(lines()[row]).toContain("▸");
terminal.click(caret + 1, row);
expect(lines()[row]).toContain("▾");
}
terminal.mouse(0, code, row);
terminal.mouse(32, code + 8, row + 1);
terminal.mouse(0, code + 8, row + 1, true);
const selection = lines();
const bar = selection.findIndex(line => line.includes("Add to chat"));
expect(bar).toBeGreaterThan(0);
terminal.click(selection[bar]!.indexOf("Add to chat"), bar);
expect(h.draft).toContain("diff --git");
expect(terminal.tui.getFocusedComponent()).toBe(terminal.editor);
terminal.input("after paste");
expect(h.draft).toContain("after paste");
expect(h.submitted).toBe(0);
} finally { h.events.get("session_shutdown")!(); terminal.stop(); }
});
117 changes: 117 additions & 0 deletions diffr/packages/pi/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { copyToClipboard, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { Component, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
import { ansiLines } from "@diffr/consumer/ansi";
import { splitArgs } from "@diffr/consumer/args";
import { PointerInput } from "@diffr/consumer/pointer";
import { openComparison, type Comparison } from "@diffr/consumer/process";
import type { Outcome } from "@diffr/consumer/frame";
import type { Frame } from "@diffr/consumer/protocol";
import { fit } from "@diffr/consumer/paint";
import { Type } from "typebox";
import { opened, openDescription } from "@diffr/consumer/open-tool";
import { mountPane } from "./layout";
import { paneKey } from "./keys";

export default function diffr(pi: ExtensionAPI) {
let active: { close(): void; focus(): void; blur(): void; toggle(): void } | undefined;
let generation = 0;
pi.registerFlag("diffr-input", { description: "Open a saved Diffr NDJSON comparison", type: "string" });
pi.registerFlag("diffr-bin", { description: "Path to the Diffr executable", type: "string" });
const close = () => { generation++; active?.close(); active = undefined; };
pi.on("session_shutdown", close);
pi.on("session_start", close);
pi.on("ui_prompt_start", () => active?.blur());

async function open(args: string[], ctx: ExtensionContext, full = false, capture = true, signal?: AbortSignal) {
if (ctx.mode !== "tui") throw new Error("Diffr requires Pi’s interactive terminal UI.");
close();
const id = generation;
let comparison: Comparison | undefined;
try {
comparison = await openComparison({ cwd: ctx.cwd, args,
binary: pi.getFlag("diffr-bin") as string | undefined, input: pi.getFlag("diffr-input") as string | undefined });
if (id !== generation) { comparison.dispose(); throw new Error("Diffr open was replaced or closed."); }
const current = comparison;
const pointer = new PointerInput();
let frame: Frame | undefined;
let layout: ReturnType<typeof mountPane>;
let closed = false;
let repaint = () => {};
const focusChat = () => {
layout.focusChat();
};
const outcome = async (value: Outcome) => {
if (value.close) { close(); return; }
if (value.chat) {
// Pi delivers pasteToEditor through handleInput; return to draft input first.
focusChat();
ctx.ui.pasteToEditor(`\n${value.chat.map(chip => chip.context).join("\n\n")}\n`);
current.pane.chatted(value.chat.map(chip => chip.name));
}
if (value.copy) {
try { await copyToClipboard(value.copy.text); current.pane.copied(value.copy.what); }
catch (error) { current.pane.copied(value.copy.what, String(error)); }
}
if (!closed) repaint();
};
layout = mountPane(ctx.ui, (terminal, expanded, focused) => {
repaint = () => terminal.requestRender();
const component: Component = {
render(width) {
frame = current.pane.frame({ columns: width, rows: Math.max(3, Math.floor(terminal.terminal.rows * (expanded() ? 0.70 : 0.45)) - 1) });
const label = ` Diffr · ${focused() ? "review focused · Esc draft" : "draft focused · F7 review"} · F6 ${expanded() ? "shrink" : "expand"} · q close`;
return [fit(label, width).padEnd(width), ...ansiLines(frame)];
},
invalidate() {},
handleInput(data) {
const key = paneKey(data);
if (!key) return;
if (!frame) { repaint(); return; }
void outcome(current.pane.input({ press: key }));
},
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
if ((event.type === "press" || event.type === "drag") && event.button !== "left") return;
if (event.y === 0 && event.type === "press") { layout.toggle(); return { handled: true, focus: true }; }
if (!frame) return;
if (event.type === "wheel") { current.pane.scroll(event.wheelDelta ?? 0, event.x); return { handled: true, render: true }; }
const type = event.type === "press" ? "down" : event.type === "release" ? "up" : event.type === "click" ? undefined : event.type;
if (!type) return { handled: true };
const next = pointer.read(frame, { type, x: event.x, y: event.y - 1, alt: event.alt });
if (next.hover !== undefined) current.pane.hover(next.hover);
if (next.input) void outcome(current.pane.input(next.input));
return { handled: true, capture: type === "down", focus: type === "down", render: true };
},
};
return component;
}, full, capture);
layout.onBlur(() => { pointer.reset(); current.pane.blur(); });
const unsubscribe = current.pane.subscribe(repaint);
active = { focus: () => layout.focus(), blur: focusChat, toggle: () => { layout.toggle(); }, close() {
closed = true; pointer.reset(); unsubscribe(); current.dispose(); layout.dispose();
} };
const result = await opened(current, signal);
if (id !== generation) throw new Error("Diffr open was replaced or closed.");
return result;
} catch (error) {
comparison?.dispose();
if (id === generation) close();
throw error;
}
}
const command = async (args: string, ctx: ExtensionContext, full = false) => {
try { await open(splitArgs(args), ctx, full); }
catch (error) { ctx.ui.notify(`Diffr: ${error instanceof Error ? error.message : error}`, "error"); }
};
pi.registerTool({ name: "diffr_open", label: "Open Diffr", description: openDescription,
promptSnippet: "Open a comparison in Diffr for the person to review.",
parameters: Type.Object({ args: Type.Optional(Type.Array(Type.String())) }),
async execute(_id, params, signal, _update, ctx) {
const text = await open(params.args ?? [], ctx, false, false, signal);
return { content: [{ type: "text", text }], details: {} };
},
});
pi.registerCommand("diffr", { description: "Review a comparison above the chat draft", handler: (args, ctx) => command(args, ctx) });
pi.registerCommand("diffr-fullscreen", { description: "Toggle the expanded docked Diffr view", handler: async (args, ctx) => active && !args ? active.toggle() : command(args, ctx, true) });
pi.registerCommand("diffr-close", { description: "Close the Diffr pane", handler: async () => close() });
pi.registerShortcut("f7", { description: "Focus or open Diffr", handler: ctx => active ? active.focus() : command("", ctx) });
}
14 changes: 14 additions & 0 deletions diffr/packages/pi/src/keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { expect, test } from "vitest";
import { paneKey } from "./keys";

test("Pi terminal sequences preserve Diffr's case-sensitive viewer commands", () => {
expect(paneKey("V")?.key).toBe("V");
expect(paneKey("\r")?.key).toBe("return");
expect(paneKey("\x1b")).toEqual({ key: "escape" });
expect(paneKey("\x10")).toEqual({ key: "p", ctrl: true });
});

test("Kitty releases cannot repeat a pane command", () => {
expect(paneKey("\x1b[17;1:3~")).toBeUndefined();
expect(paneKey("\x1b[118;2:3u")).toBeUndefined();
});
17 changes: 17 additions & 0 deletions diffr/packages/pi/src/keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { isKeyRelease, parseKey } from "@earendil-works/pi-tui";
import type { KeyPress } from "@diffr/viewer/viewer";

export function paneKey(data: string): KeyPress | undefined {
if (isKeyRelease(data)) return;
const parsed = parseKey(data);
if (!parsed) return;
const parts = parsed.split("+");
let key = parts.pop()!;
const shift = parts.includes("shift");
if (/^[^\x00-\x1f\x7f]$/u.test(data)) key = data;
else if (shift && /^[a-z]$/.test(key)) key = key.toUpperCase();
return { key: key === "enter" ? "return" : key,
...(parts.includes("ctrl") ? { ctrl: true } : {}),
...(parts.includes("alt") || parts.includes("meta") ? { meta: true } : {}),
...(shift ? { shift: true } : {}) };
}
Loading
Loading