Skip to content
Merged
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
111 changes: 111 additions & 0 deletions extensions/pi-ask-user-question/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, it, vi } from "vitest";
import askUserQuestion from "../src/index.js";
import { createMockContext, createMockPi, createMockUi } from "../../test-utils/pi.js";

function registerTool() {
const pi = createMockPi();
askUserQuestion(pi as any);
return pi.tools[0];
}

describe("pi-ask-user-question", () => {
it("registers the ask_user_question tool", () => {
const tool = registerTool();

expect(tool.name).toBe("ask_user_question");
expect(tool.label).toBe("Ask User Question");
expect(tool.execute).toEqual(expect.any(Function));
});

it("returns an error when UI is unavailable", async () => {
const tool = registerTool();
const result = await tool.execute("id", { question: "Continue?" }, undefined, undefined, createMockContext({ hasUI: false }));

expect(result.content[0].text).toContain("UI is not available");
expect(result.details).toMatchObject({ question: "Continue?", answer: null, cancelled: true, mode: "input" });
});

it.each([
[true, "User confirmed: yes", "yes"],
[false, "User answered: no", "no"],
])("handles confirm mode resolving %s", async (confirmed, text, answer) => {
const tool = registerTool();
const ui = createMockUi({ confirm: vi.fn().mockResolvedValue(confirmed) });

const result = await tool.execute("id", { question: "Continue?", confirmOnly: true }, undefined, undefined, createMockContext({ ui }));

expect(ui.confirm).toHaveBeenCalledWith("Continue?", "Confirm to continue");
expect(result.content[0].text).toBe(text);
expect(result.details).toMatchObject({ answer, cancelled: false, mode: "confirm" });
});

it("trims free-form input answers", async () => {
const tool = registerTool();
const ui = createMockUi({ input: vi.fn().mockResolvedValue(" hello ") });

const result = await tool.execute("id", { question: "What?" }, undefined, undefined, createMockContext({ ui }));

expect(result.content[0].text).toBe("User answered: hello");
expect(result.details).toMatchObject({ answer: "hello", cancelled: false, mode: "input" });
});

it("treats whitespace input as cancelled", async () => {
const tool = registerTool();
const ui = createMockUi({ input: vi.fn().mockResolvedValue(" ") });

const result = await tool.execute("id", { question: "What?" }, undefined, undefined, createMockContext({ ui }));

expect(result.details).toMatchObject({ answer: null, cancelled: true, mode: "input" });
});

it("uses editor mode for multiline or initial values", async () => {
const tool = registerTool();
const ui = createMockUi({ editor: vi.fn().mockResolvedValue(" edited ") });

const result = await tool.execute("id", { question: "Edit", multiline: true, initialValue: "draft" }, undefined, undefined, createMockContext({ ui }));

expect(ui.editor).toHaveBeenCalledWith("Edit", "draft");
expect(result.details).toMatchObject({ answer: "edited", cancelled: false, mode: "editor" });
});

it("handles option selection", async () => {
const tool = registerTool();
const ui = createMockUi({ custom: vi.fn().mockImplementation((_component) => Promise.resolve("B")) });

const result = await tool.execute("id", { question: "Pick", options: ["A", "B"] }, undefined, undefined, createMockContext({ ui }));

expect(result.content[0].text).toBe("User selected: B");
expect(result.details).toMatchObject({ answer: "B", cancelled: false, mode: "select", options: ["A", "B"] });
});

it("handles cancelled option selection", async () => {
const tool = registerTool();
const ui = createMockUi({ custom: vi.fn().mockResolvedValue(null) });

const result = await tool.execute("id", { question: "Pick", options: ["A"] }, undefined, undefined, createMockContext({ ui }));

expect(result.details).toMatchObject({ answer: null, cancelled: true, mode: "select" });
});

it("prompts for a custom answer when the custom option is selected", async () => {
const tool = registerTool();
const ui = createMockUi({
custom: vi.fn().mockResolvedValue("Type your own answer…"),
input: vi.fn().mockResolvedValue("custom"),
});

const result = await tool.execute("id", { question: "Pick", options: ["A"] }, undefined, undefined, createMockContext({ ui }));

expect(ui.input).toHaveBeenCalledWith("Pick", undefined);
expect(result.details).toMatchObject({ answer: "custom", cancelled: false, mode: "input" });
});

it("does not offer a custom answer when allowCustomAnswer is false", async () => {
const tool = registerTool();
const ui = createMockUi({ custom: vi.fn().mockResolvedValue("A") });

await tool.execute("id", { question: "Pick", options: ["A"], allowCustomAnswer: false }, undefined, undefined, createMockContext({ ui }));

expect(ui.input).not.toHaveBeenCalled();
});
});
10 changes: 5 additions & 5 deletions extensions/pi-copy-code-block/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ function normalizeExtractedCode(code: string): string {
return code.replace(/\n$/, "");
}

function extractCodeBlocks(text: string): CodeBlock[] {
export function extractCodeBlocks(text: string): CodeBlock[] {
const extracted: Array<Pick<CodeBlock, "language" | "code">> = [];
const fencePattern = /^```([^\n`]*)\r?\n([\s\S]*?)^```[ \t]*$/gm;

Expand All @@ -118,7 +118,7 @@ function extractCodeBlocks(text: string): CodeBlock[] {
}));
}

function parseCopyRequest(input?: string): { request?: ParsedCopyRequest; error?: string } {
export function parseCopyRequest(input?: string): { request?: ParsedCopyRequest; error?: string } {
const tokens = (input ?? "")
.trim()
.split(/\s+/)
Expand Down Expand Up @@ -156,7 +156,7 @@ function parseCopyRequest(input?: string): { request?: ParsedCopyRequest; error?
return { request: { kind: "single", fenced, selector: token } };
}

function resolveRequestedBlock(selector: string | undefined, blocks: CodeBlock[]) {
export function resolveRequestedBlock(selector: string | undefined, blocks: CodeBlock[]) {
const normalized = selector?.trim().toLowerCase();

if (!normalized) {
Expand All @@ -177,15 +177,15 @@ function resolveRequestedBlock(selector: string | undefined, blocks: CodeBlock[]
return { error: `Unknown code block selector "${selector}". Use a number, first/f, or last/l.` };
}

function formatSingleBlockForClipboard(block: CodeBlock, fenced: boolean): string {
export function formatSingleBlockForClipboard(block: CodeBlock, fenced: boolean): string {
if (!fenced) return block.code;

const body = block.code.endsWith("\n") ? block.code : `${block.code}\n`;
const language = block.language === "text" ? "" : block.language;
return `\`\`\`${language}\n${body}\`\`\``;
}

function formatAllBlocksForClipboard(blocks: CodeBlock[], fenced: boolean): string {
export function formatAllBlocksForClipboard(blocks: CodeBlock[], fenced: boolean): string {
return blocks.map((block) => formatSingleBlockForClipboard(block, fenced)).join(extensionConfig.copyAllSeparator);
}

Expand Down
177 changes: 177 additions & 0 deletions extensions/pi-copy-code-block/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import clipboard from "clipboardy";
import piCopyCodeBlock, {
extractCodeBlocks,
formatAllBlocksForClipboard,
formatSingleBlockForClipboard,
parseCopyRequest,
resolveRequestedBlock,
} from "../src/index.js";
import { createMockContext, createMockPi, createMockUi } from "../../test-utils/pi.js";

vi.mock("clipboardy", () => ({ default: { write: vi.fn() } }));

function assistant(text: string, stopReason = "stop") {
return { type: "message", message: { role: "assistant", stopReason, content: [{ type: "text", text }] } };
}

describe("pi-copy-code-block pure helpers", () => {
it("extracts fenced code blocks with languages and normalized code", () => {
const blocks = extractCodeBlocks("before\n```ts\r\nconst a = 1;\r\n```\nmid\n```\nplain\n```\n");

expect(blocks).toEqual([
{ index: 1, language: "text", code: "plain", preview: "plain" },
{ index: 2, language: "ts", code: "const a = 1;", preview: "const a = 1;" },
]);
});

it("returns no blocks when no fences exist", () => {
expect(extractCodeBlocks("no code here")).toEqual([]);
});

it.each([
[undefined, { kind: "single", fenced: false }],
["2", { kind: "single", fenced: false, selector: "2" }],
["first", { kind: "single", fenced: false, selector: "first" }],
["last", { kind: "single", fenced: false, selector: "last" }],
["all", { kind: "all", fenced: false }],
["fenced 2", { kind: "single", fenced: true, selector: "2" }],
["fenced all", { kind: "all", fenced: true }],
])("parses copy request %s", (input, request) => {
expect(parseCopyRequest(input as string | undefined).request).toEqual(request);
});

it("rejects copy requests with too many arguments", () => {
expect(parseCopyRequest("one two").error).toContain("Too many arguments");
});

it("resolves requested blocks", () => {
const blocks = extractCodeBlocks("```a\none\n```\n```b\ntwo\n```");

expect(resolveRequestedBlock(undefined, [blocks[0]!])).toEqual({ block: blocks[0] });
expect(resolveRequestedBlock(undefined, blocks)).toEqual({ requiresPicker: true });
expect(resolveRequestedBlock("1", blocks)).toEqual({ block: blocks[0] });
expect(resolveRequestedBlock("3", blocks).error).toContain("does not exist");
expect(resolveRequestedBlock("first", blocks)).toEqual({ block: blocks[0] });
expect(resolveRequestedBlock("f", blocks)).toEqual({ block: blocks[0] });
expect(resolveRequestedBlock("last", blocks)).toEqual({ block: blocks[1] });
expect(resolveRequestedBlock("l", blocks)).toEqual({ block: blocks[1] });
});

it("formats clipboard content", () => {
const tsBlock = { index: 1, language: "ts", code: "const a = 1;", preview: "const a = 1;" };
const textBlock = { index: 2, language: "text", code: "plain", preview: "plain" };

expect(formatSingleBlockForClipboard(tsBlock, false)).toBe("const a = 1;");
expect(formatSingleBlockForClipboard(tsBlock, true)).toBe("```ts\nconst a = 1;\n```");
expect(formatSingleBlockForClipboard(textBlock, true)).toBe("```\nplain\n```");
expect(formatAllBlocksForClipboard([tsBlock, textBlock], false)).toBe("const a = 1;\n\nplain");
});
});

describe("pi-copy-code-block extension", () => {
beforeEach(() => {
vi.mocked(clipboard.write).mockReset();
});

it("registers command, shortcut, and status refresh handlers", () => {
const pi = createMockPi();

piCopyCodeBlock(pi as any);

expect(pi.commands.has("copy-code")).toBe(true);
expect(pi.shortcuts.size).toBe(1);
expect(pi.handlers.get("session_start")).toHaveLength(1);
expect(pi.handlers.get("turn_end")).toHaveLength(1);
expect(pi.handlers.get("session_tree")).toHaveLength(1);
});

it("copies the only code block from the latest completed assistant message", async () => {
const pi = createMockPi();
const ctx = createMockContext();
ctx.sessionManager.getBranch.mockReturnValue([assistant("```ts\nconst a = 1;\n```")]);
piCopyCodeBlock(pi as any);

await pi.commands.get("copy-code").handler("", ctx);

expect(clipboard.write).toHaveBeenCalledWith("const a = 1;");
expect(ctx.ui.notify).toHaveBeenCalledWith("Copied code block.", "info");
});

it("copies all blocks as fenced content", async () => {
const pi = createMockPi();
const ctx = createMockContext();
ctx.sessionManager.getBranch.mockReturnValue([assistant("```ts\none\n```\n```\ntwo\n```")]);
piCopyCodeBlock(pi as any);

await pi.commands.get("copy-code").handler("fenced all", ctx);

expect(clipboard.write).toHaveBeenCalledWith("```\ntwo\n```\n\n```ts\none\n```");
expect(ctx.ui.notify).toHaveBeenCalledWith("Copied all 2 fenced code blocks.", "info");
});

it("warns when multiple blocks require a selector without UI", async () => {
const pi = createMockPi();
const ctx = createMockContext({ hasUI: false });
ctx.sessionManager.getBranch.mockReturnValue([assistant("```\none\n```\n```\ntwo\n```")]);
piCopyCodeBlock(pi as any);

await pi.commands.get("copy-code").handler("", ctx);

expect(clipboard.write).not.toHaveBeenCalled();
});

it("uses the picker when multiple blocks exist", async () => {
const pi = createMockPi();
const ui = createMockUi({ custom: vi.fn().mockResolvedValue({ index: 2, language: "text", code: "one", preview: "one" }) });
const ctx = createMockContext({ ui });
ctx.sessionManager.getBranch.mockReturnValue([assistant("```\none\n```\n```\ntwo\n```")]);
piCopyCodeBlock(pi as any);

await pi.commands.get("copy-code").handler("", ctx);

expect(ui.custom).toHaveBeenCalled();
expect(clipboard.write).toHaveBeenCalledWith("one");
});

it("ignores incomplete assistant messages and warns for missing code", async () => {
const pi = createMockPi();
const ctx = createMockContext();
ctx.sessionManager.getBranch.mockReturnValue([assistant("```\nnope\n```", "length")]);
piCopyCodeBlock(pi as any);

await pi.commands.get("copy-code").handler("", ctx);

expect(ctx.ui.notify).toHaveBeenCalledWith("No completed assistant message found.", "warning");
});

it("warns when completed assistant messages contain no code or selector is invalid", async () => {
const pi = createMockPi();
const ctx = createMockContext();
ctx.sessionManager.getBranch.mockReturnValue([assistant("no code")]);
piCopyCodeBlock(pi as any);

await pi.commands.get("copy-code").handler("", ctx);
expect(ctx.ui.notify).toHaveBeenCalledWith("No code blocks found in the last 1 assistant message.", "warning");

ctx.ui.notify.mockClear();
ctx.sessionManager.getBranch.mockReturnValue([assistant("```\none\n```")]);
await pi.commands.get("copy-code").handler("2", ctx);
expect(ctx.ui.notify).toHaveBeenCalledWith("Code block 2 does not exist. Found 1 block(s).", "warning");
});

it("updates and clears the status hint", async () => {
const pi = createMockPi();
const ctx = createMockContext();
piCopyCodeBlock(pi as any);

ctx.sessionManager.getBranch.mockReturnValue([assistant("```\none\n```")]);
await pi.handlers.get("turn_end")![0]!({}, ctx);
expect(ctx.ui.setStatus).toHaveBeenCalledWith("copy-code", expect.stringContaining("1 code block"));

ctx.ui.setStatus.mockClear();
ctx.sessionManager.getBranch.mockReturnValue([assistant("no code")]);
await pi.handlers.get("turn_end")![0]!({}, ctx);
expect(ctx.ui.setStatus).toHaveBeenCalledWith("copy-code", undefined);
});
});
53 changes: 53 additions & 0 deletions extensions/pi-vim-quit/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import piVimQuit from "../src/index.js";
import { createMockContext, createMockPi } from "../../test-utils/pi.js";

describe("pi-vim-quit", () => {
it("registers an input handler", () => {
const pi = createMockPi();

piVimQuit(pi as any);

expect(pi.on).toHaveBeenCalledWith("input", expect.any(Function));
expect(pi.handlers.get("input")).toHaveLength(1);
});

it.each([":q", ":qa", ":wq"])("handles %s by notifying and shutting down", async (text) => {
const pi = createMockPi();
const ctx = createMockContext();
piVimQuit(pi as any);

const handler = pi.handlers.get("input")![0]!;
const result = await handler({ text, source: "user" }, ctx);

expect(result).toEqual({ action: "handled" });
expect(ctx.ui.notify).toHaveBeenCalledWith("Quitting pi…", "info");
expect(ctx.shutdown).toHaveBeenCalledOnce();
});

it("continues for non-quit input", async () => {
const pi = createMockPi();
const ctx = createMockContext();
piVimQuit(pi as any);

const handler = pi.handlers.get("input")![0]!;
const result = await handler({ text: "hello", source: "user" }, ctx);

expect(result).toEqual({ action: "continue" });
expect(ctx.ui.notify).not.toHaveBeenCalled();
expect(ctx.shutdown).not.toHaveBeenCalled();
});

it("continues for extension input without shutting down", async () => {
const pi = createMockPi();
const ctx = createMockContext();
piVimQuit(pi as any);

const handler = pi.handlers.get("input")![0]!;
const result = await handler({ text: ":q", source: "extension" }, ctx);

expect(result).toEqual({ action: "continue" });
expect(ctx.ui.notify).not.toHaveBeenCalled();
expect(ctx.shutdown).not.toHaveBeenCalled();
});
});
Loading
Loading