From 10e555d7fd53940dd8ec17b1d18b58edecb62d84 Mon Sep 17 00:00:00 2001 From: lajarre Date: Tue, 19 May 2026 11:00:09 +0100 Subject: [PATCH 1/7] chore: ignore scratch files in lint --- biome.json | 1 + eslint.config.mjs | 1 + 2 files changed, 2 insertions(+) diff --git a/biome.json b/biome.json index fc5c5ca9..efd5bea4 100644 --- a/biome.json +++ b/biome.json @@ -6,6 +6,7 @@ "!node_modules", "!.pi", "!.tree", + "!.tmp", "!doc" ], "ignoreUnknown": true diff --git a/eslint.config.mjs b/eslint.config.mjs index 21d99852..4fbae719 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,6 +7,7 @@ export default tseslint.config( "doc/**", ".pi/**", ".tree/**", + ".tmp/**", "node_modules/**", "*.js", "*.sh", From eba3b82da6e0a02e60c32f051f2fe2b7f0a71426 Mon Sep 17 00:00:00 2001 From: lajarre Date: Tue, 19 May 2026 11:56:07 +0100 Subject: [PATCH 2/7] test: cover wrapper-facing editor surface --- test/modal-editor.test.ts | 133 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/test/modal-editor.test.ts b/test/modal-editor.test.ts index d9dcfb82..700cbaae 100644 --- a/test/modal-editor.test.ts +++ b/test/modal-editor.test.ts @@ -87,6 +87,95 @@ function focusEditor(editor: ModalEditor): void { editor.focused = true; } +type WrapperFacingEditor = ModalEditor & { + actionHandlers: Map; + onSubmit: (text: string) => unknown; + onChange: (text: string) => unknown; + onEscape: () => unknown; + onCtrlD: () => unknown; + onPasteImage: (path: string) => unknown; + onExtensionShortcut: (shortcut: string) => unknown; + focused: boolean; + disableSubmit: boolean; + borderColor: (text: string) => string; +}; + +const WRAPPER_FACING_METHODS = [ + "handleInput", + "render", + "invalidate", + "getText", + "setText", + "insertTextAtCursor", + "getExpandedText", + "addToHistory", + "setAutocompleteProvider", + "setPaddingX", + "setAutocompleteMaxVisible", + "getLines", + "getCursor", + "getMode", + "onAction", +] as const satisfies readonly (keyof WrapperFacingEditor)[]; + +const WRAPPER_FACING_FIELDS = [ + "onSubmit", + "onChange", + "onEscape", + "onCtrlD", + "onPasteImage", + "onExtensionShortcut", + "actionHandlers", + "focused", + "disableSubmit", + "borderColor", +] as const satisfies readonly (keyof WrapperFacingEditor)[]; + +type DecoratedCall = + | { method: "insertTextAtCursor"; text: string } + | { method: "handleInput"; data: string } + | { method: "setText"; text: string }; + +function assertWrapperFacingSurface(editor: ModalEditor): asserts editor is WrapperFacingEditor { + const candidate = editor as WrapperFacingEditor; + + for (const method of WRAPPER_FACING_METHODS) { + assert.equal(typeof candidate[method], "function", `${method} should be a function`); + } + + for (const field of WRAPPER_FACING_FIELDS) { + assert.ok(field in candidate, `${field} should exist`); + } + + assert.ok(candidate.actionHandlers instanceof Map, "actionHandlers should be a Map"); + assert.equal(typeof candidate.focused, "boolean", "focused should be a boolean"); + assert.equal(typeof candidate.disableSubmit, "boolean", "disableSubmit should be a boolean"); + assert.equal(typeof candidate.borderColor, "function", "borderColor should be a function"); +} + +function decorateLikeImageAttachments(editor: ModalEditor): DecoratedCall[] { + assertWrapperFacingSurface(editor); + const calls: DecoratedCall[] = []; + const originalInsertTextAtCursor = editor.insertTextAtCursor.bind(editor); + const originalHandleInput = editor.handleInput.bind(editor); + const originalSetText = editor.setText.bind(editor); + + editor.insertTextAtCursor = (text: string) => { + calls.push({ method: "insertTextAtCursor", text }); + return originalInsertTextAtCursor(text); + }; + editor.handleInput = (data: string) => { + calls.push({ method: "handleInput", data }); + return originalHandleInput(data); + }; + editor.setText = (text: string) => { + calls.push({ method: "setText", text }); + return originalSetText(text); + }; + + return calls; +} + function findCursorMarkerLine(lines: string[]): string { const line = lines.find((line) => line.includes(CURSOR_MARKER)); assert.ok(line, "expected rendered lines to include CURSOR_MARKER"); @@ -485,6 +574,50 @@ function createEditorAtBufferEnd(text: string): ModalEditor { return editor; } +// --------------------------------------------------------------------------- +// Wrapper-facing editor surface +// --------------------------------------------------------------------------- + +describe("wrapper-facing editor surface", () => { + it("exposes the CustomEditor-style surface later decorators need", () => { + const editor = new ModalEditor(stubTui, stubTheme, stubKeybindings); + + assertWrapperFacingSurface(editor); + }); + + it("keeps modal behavior when a later decorator patches core methods in place", () => { + const editor = new ModalEditor(stubTui, stubTheme, stubKeybindings); + const calls = decorateLikeImageAttachments(editor); + + editor.insertTextAtCursor("abc"); + assert.equal(editor.getText(), "abc"); + + editor.setText("hello"); + assert.equal(editor.getText(), "hello"); + + editor.handleInput("!"); + assert.equal(editor.getText(), "hello!"); + assert.equal(editor.getMode(), "insert"); + + editor.handleInput("\x1b"); + assert.equal(editor.getMode(), "normal"); + + editor.handleInput("0"); + editor.handleInput("x"); + assert.equal(editor.getText(), "ello!"); + assert.equal(editor.getMode(), "normal"); + + assert.deepEqual(calls, [ + { method: "insertTextAtCursor", text: "abc" }, + { method: "setText", text: "hello" }, + { method: "handleInput", data: "!" }, + { method: "handleInput", data: "\x1b" }, + { method: "handleInput", data: "0" }, + { method: "handleInput", data: "x" }, + ]); + }); +}); + // --------------------------------------------------------------------------- // Mode transitions // --------------------------------------------------------------------------- From fd127d760b565cc081765e682a577b3fe8f4ec0b Mon Sep 17 00:00:00 2001 From: lajarre Date: Tue, 19 May 2026 12:02:54 +0100 Subject: [PATCH 3/7] test: add image attachments e2e --- script/image-attachments-e2e.ts | 660 ++++++++++++++++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 script/image-attachments-e2e.ts diff --git a/script/image-attachments-e2e.ts b/script/image-attachments-e2e.ts new file mode 100644 index 00000000..6e30565c --- /dev/null +++ b/script/image-attachments-e2e.ts @@ -0,0 +1,660 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import installPiVim from "../index.js"; +import { + createExtensionApiHarness, + stubTheme, + stubTui, +} from "../test/harness.js"; +import type { stubKeybindings } from "../test/harness.js"; + +type RuntimeEditorFactory = ( + tui: typeof stubTui, + theme: typeof stubTheme, + keybindings: typeof stubKeybindings, +) => unknown; + +type WidgetCall = { + key: string; + content: string[] | undefined; + options: { placement?: string } | undefined; +}; + +type NotificationCall = { + message: string; + type: string; +}; + +type SentUserMessage = { + content: unknown; + options: unknown; +}; + +type RuntimeContext = { + cwd: string; + hasUI: boolean; + isIdle(): boolean; + ui: { + theme: typeof stubTheme; + setWidget(key: string, content: string[] | undefined, options?: { placement?: string }): void; + setEditorComponent(factory: RuntimeEditorFactory | undefined): void; + getEditorComponent(): RuntimeEditorFactory | undefined; + notify(message: string, type: string): void; + }; + shutdown(): void; +}; + +type RuntimeHarness = { + ctx: RuntimeContext; + pi: ReturnType; + widgetCalls: WidgetCall[]; + notifications: NotificationCall[]; + sentUserMessages: SentUserMessage[]; + getEditorFactory(): RuntimeEditorFactory; +}; + +type EditorSurface = { + render(width: number): string[]; + invalidate(): void; + handleInput(data: string): void; + getText(): string; + setText(text: string): void; + insertTextAtCursor(text: string): void; + getExpandedText(): string; + addToHistory(text: string): void; + setAutocompleteProvider(provider: unknown): void; + setPaddingX(padding: number): void; + setAutocompleteMaxVisible(maxVisible: number): void; + getLines(): string[]; + getCursor(): { line: number; col: number }; + getMode(): string; + onAction(action: string, handler: () => void): void; +}; + +type PiExtension = (pi: unknown) => void; + +type TransformInputResult = { + action: "transform"; + text: string; + images?: unknown[]; +}; + +type ImageContent = { + type: "image"; + data: string; + mimeType: string; +}; + +const IMAGE_PACKAGE_NAME = "@jordyvd/pi-image-attachments"; +const IMAGE_PACKAGE_REGISTRY_RANGE = "^0.1.1"; +const BRACKETED_PASTE_START = "\x1b[200~"; +const BRACKETED_PASTE_END = "\x1b[201~"; +const SUBMIT_INPUT = "\r"; +const PNG_BYTES = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", + "base64", +); + +const WRAPPER_FACING_METHODS = [ + "handleInput", + "render", + "invalidate", + "getText", + "setText", + "insertTextAtCursor", + "getExpandedText", + "addToHistory", + "setAutocompleteProvider", + "setPaddingX", + "setAutocompleteMaxVisible", + "getLines", + "getCursor", + "getMode", + "onAction", +] as const; + +const WRAPPER_FACING_FIELDS = [ + "onSubmit", + "onChange", + "onEscape", + "onCtrlD", + "onPasteImage", + "onExtensionShortcut", + "actionHandlers", + "focused", + "disableSubmit", + "borderColor", +] as const; + +const currentRequire = createRequire(import.meta.url); +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function formatUnknownError(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +function fail(message: string): never { + throw new Error(message); +} + +function crossPackageBlocker(message: string): never { + throw new Error(`cross-package blocker: ${message}`); +} + +function readPackageName(packageJsonPath: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as unknown; + } catch (error) { + throw new Error( + `FAIL-INFRA: unable to read or parse package.json at ${packageJsonPath}: ${formatUnknownError(error)}`, + { cause: error }, + ); + } + + if (isRecord(parsed) && typeof parsed.name === "string") return parsed.name; + return null; +} + +function hasPackageName(packageDir: string, expectedName: string): boolean { + const packageJsonPath = join(packageDir, "package.json"); + return existsSync(packageJsonPath) && readPackageName(packageJsonPath) === expectedName; +} + +function findPackageRoot(specifier: string): string { + const nodeModulesCandidate = join(projectRoot, "node_modules", ...specifier.split("/")); + if (hasPackageName(nodeModulesCandidate, specifier)) return nodeModulesCandidate; + + let dir: string; + try { + dir = dirname(currentRequire.resolve(specifier)); + } catch (error) { + if (isRecord(error) && error.code === "MODULE_NOT_FOUND") { + throw new Error(`FAIL-INFRA: unable to locate installed package root for ${specifier}`); + } + throw new Error( + `FAIL-INFRA: unable to resolve installed package root for ${specifier}: ${formatUnknownError(error)}`, + { cause: error }, + ); + } + + while (true) { + const packageJsonPath = join(dir, "package.json"); + if (existsSync(packageJsonPath) && readPackageName(packageJsonPath) === specifier) { + return dir; + } + + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + + throw new Error(`FAIL-INFRA: unable to locate installed package root for ${specifier}`); +} + +function packLocalImageAttachments(packageDir: string, workspace: string): string { + try { + const output = execFileSync("npm", ["pack", packageDir, "--pack-destination", workspace], { + cwd: workspace, + encoding: "utf8", + env: { + ...process.env, + npm_config_ignore_scripts: "true", + }, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + const tarballName = output.split("\n").filter(Boolean).at(-1); + if (!tarballName) throw new Error("npm pack did not report a tarball name"); + return `file:${join(workspace, tarballName)}`; + } catch (error) { + throw new Error(`FAIL-INFRA: unable to pack ${IMAGE_PACKAGE_NAME}: ${formatUnknownError(error)}`); + } +} + +function resolveImageAttachmentsDependency(workspace: string): string { + const explicitCandidate = process.env.PI_IMAGE_ATTACHMENTS_PACKAGE_DIR; + if (explicitCandidate) { + const packageDir = resolve(explicitCandidate); + if (!hasPackageName(packageDir, IMAGE_PACKAGE_NAME)) { + throw new Error( + `FAIL-INFRA: PI_IMAGE_ATTACHMENTS_PACKAGE_DIR does not point to ${IMAGE_PACKAGE_NAME}: ${packageDir}`, + ); + } + return packLocalImageAttachments(packageDir, workspace); + } + + const candidates = [ + resolve(projectRoot, "../pi-image-attachments"), + resolve(projectRoot, "../../pi-image-attachments"), + ]; + + for (const candidate of candidates) { + if (hasPackageName(candidate, IMAGE_PACKAGE_NAME)) { + return packLocalImageAttachments(candidate, workspace); + } + } + + return IMAGE_PACKAGE_REGISTRY_RANGE; +} + +function runNpmInstall(workspace: string): void { + try { + execFileSync("npm", ["install", "--ignore-scripts"], { + cwd: workspace, + encoding: "utf8", + env: { + ...process.env, + npm_config_audit: "false", + npm_config_fund: "false", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + const output = isRecord(error) + ? [error.stdout, error.stderr].filter((value): value is string => typeof value === "string").join("\n") + : ""; + throw new Error( + `FAIL-INFRA: npm install --ignore-scripts failed${output ? `\n${output}` : ""}`, + ); + } +} + +async function createWorkspace(): Promise { + const workspace = await mkdtemp(join(tmpdir(), "pi-vim-image-attachments-e2e-")); + const packageJson = { + private: true, + type: "module", + dependencies: { + [IMAGE_PACKAGE_NAME]: resolveImageAttachmentsDependency(workspace), + "@mariozechner/pi-ai": `file:${findPackageRoot("@mariozechner/pi-ai")}`, + "@mariozechner/pi-coding-agent": `file:${findPackageRoot("@mariozechner/pi-coding-agent")}`, + "@mariozechner/pi-tui": `file:${findPackageRoot("@mariozechner/pi-tui")}`, + }, + }; + + await writeFile(join(workspace, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`); + runNpmInstall(workspace); + await writeFile(join(workspace, "fixture.png"), PNG_BYTES); + return workspace; +} + +async function importImageAttachmentsExtension(workspace: string): Promise { + try { + const workspaceRequire = createRequire(join(workspace, "package.json")); + const entry = workspaceRequire.resolve(`${IMAGE_PACKAGE_NAME}/index.ts`); + const module = await import(pathToFileURL(entry).href) as unknown; + + if (!isRecord(module) || typeof module.default !== "function") { + throw new Error(`${IMAGE_PACKAGE_NAME} default export is not a function`); + } + + return module.default as PiExtension; + } catch (error) { + throw new Error(`FAIL-INFRA: unable to import ${IMAGE_PACKAGE_NAME}: ${formatUnknownError(error)}`); + } +} + +function createPiHarness() { + const sentUserMessages: SentUserMessage[] = []; + const pi = Object.assign(createExtensionApiHarness(), { + sentUserMessages, + sendUserMessage(content: unknown, options?: unknown): void { + sentUserMessages.push({ content, options }); + }, + }); + + return pi; +} + +function createRuntimeHarness(cwd: string, pi: ReturnType): RuntimeHarness { + let editorFactory: RuntimeEditorFactory | undefined; + const widgetCalls: WidgetCall[] = []; + const notifications: NotificationCall[] = []; + + const ctx: RuntimeContext = { + cwd, + hasUI: true, + isIdle() { + return true; + }, + ui: { + theme: stubTheme, + setWidget(key: string, content: string[] | undefined, options?: { placement?: string }) { + widgetCalls.push({ key, content, options }); + }, + setEditorComponent(factory: RuntimeEditorFactory | undefined) { + editorFactory = factory; + }, + getEditorComponent() { + return editorFactory; + }, + notify(message: string, type: string) { + notifications.push({ message, type }); + }, + }, + shutdown() {}, + }; + + return { + ctx, + pi, + widgetCalls, + notifications, + sentUserMessages: pi.sentUserMessages, + getEditorFactory() { + if (!editorFactory) throw new Error("expected an installed editor factory"); + return editorFactory; + }, + }; +} + +function createE2eKeybindings(): typeof stubKeybindings { + return { + matches(data: string, action: string): boolean { + return data === SUBMIT_INPUT && action === "tui.input.submit"; + }, + } as typeof stubKeybindings; +} + +async function installSupportedOrder( + workspace: string, + imageExtension: PiExtension, +): Promise { + const pi = createPiHarness(); + const harness = createRuntimeHarness(workspace, pi); + + installPiVim(pi); + imageExtension(pi); + + await pi.emit("session_start", { reason: "startup" }, harness.ctx); + return harness; +} + +function assertEditorSurface(editor: unknown, label: string): asserts editor is EditorSurface { + if (!isRecord(editor)) fail(`${label} is not an object`); + + for (const method of WRAPPER_FACING_METHODS) { + if (typeof editor[method] !== "function") { + fail(`${label} is missing method ${method}`); + } + } + + for (const field of WRAPPER_FACING_FIELDS) { + if (!(field in editor)) { + fail(`${label} is missing field ${field}`); + } + } + + if (!(editor.actionHandlers instanceof Map)) fail(`${label} actionHandlers is not a Map`); + if (typeof editor.focused !== "boolean") fail(`${label} focused is not a boolean`); + if (typeof editor.disableSubmit !== "boolean") fail(`${label} disableSubmit is not a boolean`); + if (typeof editor.borderColor !== "function") fail(`${label} borderColor is not a function`); + if (typeof editor.getMode !== "function") fail(`${label} getMode is not a function`); +} + +function assertPiVimSurfaceForLaterDecorator(editor: unknown): asserts editor is EditorSurface { + try { + assertEditorSurface(editor, "later image-attachments editor"); + } catch (error) { + crossPackageBlocker(formatUnknownError(error)); + } +} + +function disableClipboardWrites(editor: EditorSurface): void { + const candidate = editor as EditorSurface & { + setClipboardFn?: (fn: (text: string) => unknown) => void; + setClipboardReadFn?: (fn: () => string | null) => void; + }; + + candidate.setClipboardFn?.(() => {}); + candidate.setClipboardReadFn?.(() => null); +} + +function mountEditor(harness: RuntimeHarness): EditorSurface { + const editor = harness.getEditorFactory()(stubTui, stubTheme, createE2eKeybindings()); + assertPiVimSurfaceForLaterDecorator(editor); + disableClipboardWrites(editor); + return editor; +} + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + fail(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +function assertIncludes(haystack: string, needle: string, message: string): void { + if (!haystack.includes(needle)) { + fail(`${message}: expected ${JSON.stringify(haystack)} to include ${JSON.stringify(needle)}`); + } +} + +function assertArrayLength(value: unknown, expectedLength: number, message: string): asserts value is unknown[] { + if (!Array.isArray(value)) fail(`${message}: expected an array`); + assertEqual(value.length, expectedLength, message); +} + +function bracketedPaste(text: string): string { + return `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`; +} + +function typeText(editor: EditorSurface, text: string): void { + for (const char of text) editor.handleInput(char); +} + +function latestWidget(harness: RuntimeHarness, messagePrefix: string): WidgetCall { + const widget = harness.widgetCalls.at(-1); + if (!widget) fail(`${messagePrefix} should publish a widget update`); + return widget; +} + +function assertWidgetHasAttachment(harness: RuntimeHarness, messagePrefix: string): void { + const widget = latestWidget(harness, messagePrefix); + if (!widget.content) fail(`${messagePrefix} should publish an attachments widget`); + assertIncludes( + widget.content.join("\n"), + "[Image #1]", + `${messagePrefix} attachments widget should include the placeholder`, + ); +} + +function assertWidgetCleared(harness: RuntimeHarness, messagePrefix: string): void { + const widget = latestWidget(harness, messagePrefix); + assertEqual(widget.content, undefined, `${messagePrefix} should clear the attachments widget`); +} + +function assertImageContent(value: unknown, message: string): asserts value is ImageContent { + if (!isRecord(value)) fail(`${message}: expected image content object`); + assertEqual(value.type, "image", `${message} type`); + assertEqual(value.mimeType, "image/png", `${message} mimeType`); + if (typeof value.data !== "string" || value.data.length === 0) { + fail(`${message}: expected non-empty base64 data`); + } +} + +function assertTransformResult(value: unknown, message: string): asserts value is TransformInputResult { + if (!isRecord(value)) fail(`${message}: expected object result`); + assertEqual(value.action, "transform", `${message} action`); + if (typeof value.text !== "string") fail(`${message}: expected string text`); + if (value.images !== undefined && !Array.isArray(value.images)) { + fail(`${message}: expected images array when images are present`); + } +} + +function assertImageAttachmentState( + editor: EditorSurface, + harness: RuntimeHarness, + messagePrefix: string, +): void { + assertEqual( + editor.getText(), + "[Image #1] ", + `${messagePrefix} should insert an attachment placeholder`, + ); + assertWidgetHasAttachment(harness, messagePrefix); +} + +function assertImageAttachmentInsertedByDirectInsert( + editor: EditorSurface, + harness: RuntimeHarness, + imagePath: string, +): void { + editor.insertTextAtCursor(imagePath); + assertImageAttachmentState(editor, harness, "direct image path insert"); +} + +function assertImageAttachmentInsertedByBracketedPaste( + editor: EditorSurface, + harness: RuntimeHarness, + imagePath: string, +): void { + editor.handleInput(bracketedPaste(imagePath)); + assertImageAttachmentState(editor, harness, "bracketed image paste"); +} + +function assertPiVimModalBehavior(editor: EditorSurface): void { + assertEqual(editor.getMode(), "insert", "editor should start in INSERT mode"); + + typeText(editor, "abc"); + assertEqual(editor.getText(), "abc", "INSERT input should update editor text"); + + editor.handleInput("\x1b"); + assertEqual(editor.getMode(), "normal", "escape should enter NORMAL mode"); + + editor.handleInput("0"); + editor.handleInput("x"); + assertEqual( + editor.getText(), + "bc", + "NORMAL printable input should be handled by pi-vim instead of inserted as raw text", + ); +} + +async function assertTextAndImageSubmit( + harness: RuntimeHarness, + editor: EditorSurface, + imagePath: string, +): Promise { + typeText(editor, "Look "); + editor.insertTextAtCursor(imagePath); + assertEqual(editor.getText(), "Look [Image #1] ", "text plus image draft text"); + assertWidgetHasAttachment(harness, "text plus image submit"); + + const submittedText = editor.getExpandedText().trim(); + editor.handleInput(SUBMIT_INPUT); + const results = await harness.pi.emit("input", { text: submittedText, images: [] }, harness.ctx); + assertArrayLength(results, 1, "text plus image input hook result count"); + + const result = results[0]; + assertTransformResult(result, "text plus image input hook result"); + assertEqual(result.text, "Look", "text plus image submit should strip placeholder"); + assertArrayLength(result.images, 1, "text plus image submit should include one image content item"); + assertImageContent(result.images[0], "text plus image submit image"); + assertWidgetCleared(harness, "text plus image submit"); +} + +function assertImageOnlySubmit( + harness: RuntimeHarness, + editor: EditorSurface, + imagePath: string, +): void { + editor.insertTextAtCursor(imagePath); + assertImageAttachmentState(editor, harness, "image-only submit"); + + editor.handleInput(SUBMIT_INPUT); + assertArrayLength(harness.sentUserMessages, 1, "image-only submit should send one message"); + + const message = harness.sentUserMessages[0]; + if (!message) fail("image-only submit should capture a message"); + assertArrayLength(message.content, 1, "image-only submit should send one image block"); + assertImageContent(message.content[0], "image-only submit image"); + assertEqual(editor.getText(), "", "image-only submit should clear editor text"); + assertWidgetCleared(harness, "image-only submit"); +} + +function assertNormalDeletionClearsDraft( + harness: RuntimeHarness, + editor: EditorSurface, + imagePath: string, +): void { + editor.insertTextAtCursor(imagePath); + assertImageAttachmentState(editor, harness, "normal deletion"); + + for (const key of ["\x1b", "0", "1", "1", "x"]) { + editor.handleInput(key); + } + + assertEqual(editor.getMode(), "normal", "normal deletion should leave editor in NORMAL mode"); + assertEqual(editor.getText(), "", "normal deletion should remove the placeholder text"); + assertWidgetCleared(harness, "normal deletion"); +} + +async function verifySupportedOrder(workspace: string, imageExtension: PiExtension): Promise { + const imagePath = join(workspace, "fixture.png"); + + try { + const surfaceHarness = await installSupportedOrder(workspace, imageExtension); + assertPiVimSurfaceForLaterDecorator(mountEditor(surfaceHarness)); + + const modalHarness = await installSupportedOrder(workspace, imageExtension); + assertPiVimModalBehavior(mountEditor(modalHarness)); + + const directImageHarness = await installSupportedOrder(workspace, imageExtension); + assertImageAttachmentInsertedByDirectInsert( + mountEditor(directImageHarness), + directImageHarness, + imagePath, + ); + + const bracketedImageHarness = await installSupportedOrder(workspace, imageExtension); + assertImageAttachmentInsertedByBracketedPaste( + mountEditor(bracketedImageHarness), + bracketedImageHarness, + imagePath, + ); + + const textAndImageHarness = await installSupportedOrder(workspace, imageExtension); + await assertTextAndImageSubmit( + textAndImageHarness, + mountEditor(textAndImageHarness), + imagePath, + ); + + const imageOnlyHarness = await installSupportedOrder(workspace, imageExtension); + assertImageOnlySubmit(imageOnlyHarness, mountEditor(imageOnlyHarness), imagePath); + + const deletionHarness = await installSupportedOrder(workspace, imageExtension); + assertNormalDeletionClearsDraft(deletionHarness, mountEditor(deletionHarness), imagePath); + } catch (error) { + const message = formatUnknownError(error); + if (message.startsWith("cross-package blocker:")) throw error; + crossPackageBlocker(message); + } +} + +async function main(): Promise { + const workspace = await createWorkspace(); + console.log("image-attachments-e2e: npm install --ignore-scripts completed"); + + const imageExtension = await importImageAttachmentsExtension(workspace); + await verifySupportedOrder(workspace, imageExtension); + + console.log("PASS pi-vim then image-attachments"); + console.log("PASS image-attachments-e2e"); +} + +void main().catch((error: unknown) => { + console.error(formatUnknownError(error)); + process.exitCode = 1; +}); From e3d457afe63728025e6044fcf9064b805b71eff6 Mon Sep 17 00:00:00 2001 From: lajarre Date: Tue, 19 May 2026 12:18:20 +0100 Subject: [PATCH 4/7] docs: document wrapper load order --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 906c3999..2424908d 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,24 @@ Clipboard write mirroring is controlled by `piVim.clipboardMirror`: The setting controls write mirroring only. `p` / `P` keep the paste policy documented below. +## wrapping pi-vim + +Supported order: `pi-vim` first, `@jordyvd/pi-image-attachments` wrapper second. + +pi-vim does not call `ctx.ui.getEditorComponent()`; the later wrapper does. The inverse order is not supported. + +Wrappers must decorate pi-vim in place or forward each unintercepted property, method, and callback. Preserve lifecycle (`handleInput`, `render`, `invalidate`), text methods (`getText`, `setText`, `insertTextAtCursor`, `getExpandedText`), app callbacks (`onSubmit`, `onChange`, `onEscape`, `onCtrlD`, `onPasteImage`, `onExtensionShortcut`), `actionHandlers`, flags (`focused`, `disableSubmit`), and state reads (`getLines`, `getCursor`, `getMode()`). + +#18/#21 previous-editor delegation is intentionally not adopted: no previous-extension wrapping, insert delegate, or generic composition layer. + +Manual smoke (sibling image-attachments checkout): + +```bash +pi -e ./index.ts -e ../pi-image-attachments/index.ts +``` + +Check: insert text; add/paste an image path; see `[Image #1]` plus widget; submit text+image with placeholder stripped; switch INSERT/NORMAL modes. + ## contributor setup Hooks install with `npm install` after cloning. To wire them explicitly: From 34ceff0d01a4c5c41f7a6d5c8c29f0d6650a7952 Mon Sep 17 00:00:00 2001 From: lajarre Date: Tue, 19 May 2026 16:03:27 +0100 Subject: [PATCH 5/7] fix: support worktree image e2e --- README.md | 15 ++++++----- script/image-attachments-e2e.ts | 47 +++++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2424908d..e814b27b 100644 --- a/README.md +++ b/README.md @@ -34,21 +34,22 @@ The setting controls write mirroring only. `p` / `P` keep the paste policy docum ## wrapping pi-vim -Supported order: `pi-vim` first, `@jordyvd/pi-image-attachments` wrapper second. +Supported: `pi-vim` first, `@jordyvd/pi-image-attachments` second. pi-vim does not call `ctx.ui.getEditorComponent()`; the wrapper does. Inverse order unsupported. -pi-vim does not call `ctx.ui.getEditorComponent()`; the later wrapper does. The inverse order is not supported. +Wrappers must decorate in place or forward unintercepted surface: lifecycle (`handleInput`, `render`, `invalidate`), text (`getText`, `setText`, `insertTextAtCursor`, `getExpandedText`), callbacks (`onSubmit`, `onChange`, `onEscape`, `onCtrlD`, `onPasteImage`, `onExtensionShortcut`), `actionHandlers`, flags (`focused`, `disableSubmit`), reads (`getLines`, `getCursor`, `getMode()`). -Wrappers must decorate pi-vim in place or forward each unintercepted property, method, and callback. Preserve lifecycle (`handleInput`, `render`, `invalidate`), text methods (`getText`, `setText`, `insertTextAtCursor`, `getExpandedText`), app callbacks (`onSubmit`, `onChange`, `onEscape`, `onCtrlD`, `onPasteImage`, `onExtensionShortcut`), `actionHandlers`, flags (`focused`, `disableSubmit`), and state reads (`getLines`, `getCursor`, `getMode()`). +#18/#21 delegation is not adopted: no previous-extension wrapping, insert delegate, or generic composition layer. -#18/#21 previous-editor delegation is intentionally not adopted: no previous-extension wrapping, insert delegate, or generic composition layer. - -Manual smoke (sibling image-attachments checkout): +Manual smoke. If raw `-e` cannot resolve Pi peer packages, run `npm install --ignore-scripts --package-lock=false` in the image checkout. ```bash +# repo root pi -e ./index.ts -e ../pi-image-attachments/index.ts +# this worktree +pi -e ./index.ts -e ../../../pi-image-attachments/index.ts ``` -Check: insert text; add/paste an image path; see `[Image #1]` plus widget; submit text+image with placeholder stripped; switch INSERT/NORMAL modes. +Check: insert text; add/paste image path; see `[Image #1]` widget; submit text+image stripped; switch INSERT/NORMAL modes. ## contributor setup diff --git a/script/image-attachments-e2e.ts b/script/image-attachments-e2e.ts index 6e30565c..6f381694 100644 --- a/script/image-attachments-e2e.ts +++ b/script/image-attachments-e2e.ts @@ -135,6 +135,15 @@ const WRAPPER_FACING_FIELDS = [ const currentRequire = createRequire(import.meta.url); const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +function createNpmCommandEnv(): NodeJS.ProcessEnv { + const env = { ...process.env }; + delete env.NPM_CONFIG_BEFORE; + delete env.npm_config_before; + delete env.NPM_CONFIG_MIN_RELEASE_AGE; + delete env.npm_config_min_release_age; + return env; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -172,9 +181,24 @@ function hasPackageName(packageDir: string, expectedName: string): boolean { return existsSync(packageJsonPath) && readPackageName(packageJsonPath) === expectedName; } +function findPackageRootInAncestorNodeModules(specifier: string): string | null { + let dir = projectRoot; + + while (true) { + const nodeModulesCandidate = join(dir, "node_modules", ...specifier.split("/")); + if (hasPackageName(nodeModulesCandidate, specifier)) return nodeModulesCandidate; + + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + + return null; +} + function findPackageRoot(specifier: string): string { - const nodeModulesCandidate = join(projectRoot, "node_modules", ...specifier.split("/")); - if (hasPackageName(nodeModulesCandidate, specifier)) return nodeModulesCandidate; + const ancestorNodeModulesPackage = findPackageRootInAncestorNodeModules(specifier); + if (ancestorNodeModulesPackage) return ancestorNodeModulesPackage; let dir: string; try { @@ -209,7 +233,7 @@ function packLocalImageAttachments(packageDir: string, workspace: string): strin cwd: workspace, encoding: "utf8", env: { - ...process.env, + ...createNpmCommandEnv(), npm_config_ignore_scripts: "true", }, stdio: ["ignore", "pipe", "pipe"], @@ -234,10 +258,17 @@ function resolveImageAttachmentsDependency(workspace: string): string { return packLocalImageAttachments(packageDir, workspace); } - const candidates = [ - resolve(projectRoot, "../pi-image-attachments"), - resolve(projectRoot, "../../pi-image-attachments"), - ]; + const candidates = new Set(); + let dir = projectRoot; + + while (true) { + candidates.add(resolve(dir, "../pi-image-attachments")); + candidates.add(resolve(dir, "pi-image-attachments")); + + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } for (const candidate of candidates) { if (hasPackageName(candidate, IMAGE_PACKAGE_NAME)) { @@ -254,7 +285,7 @@ function runNpmInstall(workspace: string): void { cwd: workspace, encoding: "utf8", env: { - ...process.env, + ...createNpmCommandEnv(), npm_config_audit: "false", npm_config_fund: "false", }, From 4119b132e8f4981b3846926c7dad769c9858d8a4 Mon Sep 17 00:00:00 2001 From: lajarre Date: Wed, 20 May 2026 00:44:02 +0100 Subject: [PATCH 6/7] test: pin image attachments e2e version --- script/image-attachments-e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/image-attachments-e2e.ts b/script/image-attachments-e2e.ts index 6f381694..065d22e0 100644 --- a/script/image-attachments-e2e.ts +++ b/script/image-attachments-e2e.ts @@ -92,7 +92,7 @@ type ImageContent = { }; const IMAGE_PACKAGE_NAME = "@jordyvd/pi-image-attachments"; -const IMAGE_PACKAGE_REGISTRY_RANGE = "^0.1.1"; +const IMAGE_PACKAGE_REGISTRY_RANGE = "0.1.1"; const BRACKETED_PASTE_START = "\x1b[200~"; const BRACKETED_PASTE_END = "\x1b[201~"; const SUBMIT_INPUT = "\r"; From 5829c794320c21ea0f0cfae10cb6495f85107d63 Mon Sep 17 00:00:00 2001 From: lajarre Date: Wed, 20 May 2026 12:53:25 +0100 Subject: [PATCH 7/7] chore(release): bump version to 0.8.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 39a8e068..9c358d29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-vim", - "version": "0.7.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-vim", - "version": "0.7.0", + "version": "0.8.0", "license": "MIT", "devDependencies": { "@biomejs/biome": "2.4.8", diff --git a/package.json b/package.json index c2223632..0c449939 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-vim", - "version": "0.7.0", + "version": "0.8.0", "description": "Vim-style modal editing for Pi's TUI editor", "type": "module", "keywords": [