import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createPiSession, type PiSession, } from "../../../test/fixture/create-pi-session.ts"; const extensionPath = fileURLToPath(new URL("../index.ts", import.meta.url)); const DEFAULTS = ["alt+n", "ctrl+enter", "ctrl+alt+enter"]; type UICall = { method: string; args: unknown[] }; function buildRecordingUI(calls: UICall[]): ExtensionUIContext { const record = (method: string, args: unknown[]): void => { calls.push({ method, args }); }; const ui = { async select(title: string, options: string[]) { record("select", [title, options]); return options[0]; }, async confirm(title: string, message: string) { record("confirm", [title, message]); return true; }, async input(title: string, placeholder?: string) { record("input", [title, placeholder]); return ""; }, async editor(title: string, prefill?: string) { record("editor", [title, prefill]); return ""; }, notify(message: string, type?: string) { record("notify", [message, type]); }, onTerminalInput() { return () => {}; }, setStatus(key: string, text: string) { record("setStatus", [key, text]); }, setWorkingMessage(message: string) { record("setWorkingMessage", [message]); }, setWidget(key: string, content: unknown) { record("setWidget", [key, content]); }, setFooter(...args: unknown[]) { record("setFooter", args); }, setHeader(...args: unknown[]) { record("setHeader", args); }, setTitle(title: string) { record("setTitle", [title]); }, async custom() { return undefined; }, pasteToEditor(...args: unknown[]) { record("pasteToEditor", args); }, setEditorText(...args: unknown[]) { record("setEditorText", args); }, getEditorText() { return ""; }, setEditorComponent(...args: unknown[]) { record("setEditorComponent", args); }, get theme() { return { fg: (_color: unknown, text: string) => text, bold: (text: string) => text, italic: (text: string) => text, strikethrough: (text: string) => text, }; }, getAllThemes() { return []; }, getTheme() { return undefined; }, setTheme() { return { success: false, error: "Test mode" }; }, getToolsExpanded() { return false; }, setToolsExpanded() {}, }; return ui as unknown as ExtensionUIContext; } type Case = { user?: unknown; project?: unknown; env?: Record; }; describe("notes shortcut settings", () => { let session: PiSession | undefined; let root: string | undefined; afterEach(async () => { await session?.dispose(); session = undefined; vi.restoreAllMocks(); if (root) rmSync(root, { recursive: true, force: true }); root = undefined; }); async function start(input: Case) { root = mkdtempSync(path.join(tmpdir(), "pi-ext-notes-settings-")); if ("project" in input) { const dir = path.join(root, ".pi"); mkdirSync(dir, { recursive: true }); writeFileSync( path.join(dir, "settings.json"), JSON.stringify({ notes: input.project }), ); } // Shortcuts load before any session, from process.cwd(). vi.spyOn(process, "cwd").mockReturnValue(root); session = await createPiSession({ cwd: root, extensionPaths: [extensionPath], ...("user" in input ? { userSettings: { notes: input.user } } : {}), ...(input.env ? { env: input.env } : {}), }); const extension = session.extensions[0]; if (!extension) throw new Error("missing extension"); const calls: UICall[] = []; session.session.extensionRunner.setUIContext( buildRecordingUI(calls), "print", ); const before = calls.filter((call) => call.method === "notify").length; await session.session.extensionRunner.emit({ type: "session_start", reason: "startup", }); const warnings = calls .filter((call) => call.method === "notify") .slice(before) .filter((call) => call.args[1] === "warning") .map((call) => call.args[0]); return { shortcuts: [...extension.shortcuts.keys()], warnings }; } it.each([ ["default", {}, DEFAULTS], [ "user", { user: { toggleShortcut: "alt+j", addFromEditorShortcut: "alt+k", sendPickerShortcut: "alt+l", }, }, ["alt+j", "alt+k", "alt+l"], ], [ "env over user", { user: { toggleShortcut: "alt+j" }, env: { PI_NOTES_TOGGLE_SHORTCUT: "alt+x", PI_NOTES_ADD_FROM_EDITOR_SHORTCUT: "alt+y", PI_NOTES_SEND_PICKER_SHORTCUT: "alt+z", }, }, ["alt+x", "alt+y", "alt+z"], ], [ "trusted project ignored", { user: { toggleShortcut: "alt+j" }, project: { toggleShortcut: "alt+p", sendPickerShortcut: "alt+q" }, }, ["alt+j", "ctrl+enter", "ctrl+alt+enter"], ], [ "swapped shortcuts stay unique", { user: { toggleShortcut: "ctrl+enter", addFromEditorShortcut: "alt+n", }, }, ["ctrl+enter", "alt+n", "ctrl+alt+enter"], ], ] satisfies [string, Case, string[]][])( "%s", async (_name, input, expected) => { const { shortcuts, warnings } = await start(input); expect(shortcuts).toEqual(expected); expect(warnings).toEqual([]); }, ); it.each([ [ "wrong type", { user: { toggleShortcut: 1 } }, [ "Invalid notes.toggleShortcut from user settings: must be a non-empty string", ], ], [ "blank user value", { user: { sendPickerShortcut: " " } }, [ "Invalid notes.sendPickerShortcut from user settings: must be a non-empty string", ], ], [ "empty env does not fall through to user", { user: { addFromEditorShortcut: "alt+k" }, env: { PI_NOTES_ADD_FROM_EDITOR_SHORTCUT: "" }, }, [ "Invalid notes.addFromEditorShortcut from environment variable PI_NOTES_ADD_FROM_EDITOR_SHORTCUT: must be a non-empty string", ], ], [ "duplicates", { user: { toggleShortcut: "alt+j", sendPickerShortcut: "alt+j" } }, ["Duplicate notes shortcuts: alt+j, ctrl+enter, alt+j"], ], ] satisfies [string, Case, string[]][])( "%s warns and keeps defaults", async (_name, input, expected) => { const { shortcuts, warnings } = await start(input); expect(shortcuts).toEqual(DEFAULTS); expect(warnings).toEqual(expected); }, ); });