From 93a2204748c8c1fd078c64a36a8c50d6305d4f7b Mon Sep 17 00:00:00 2001 From: onychen <2752845347@qq.com> Date: Sun, 6 Sep 2026 12:40:54 +0800 Subject: [PATCH 1/3] feat(tui): compact pasted image placeholders --- README.md | 2 +- extensions/image-paste/index.ts | 430 ++++++++++++++++++ extensions/shared/below-editor-navigation.ts | 9 + tests/extensions/image-paste/index.test.ts | 183 ++++++++ tests/extensions/shared/editor-layers.test.ts | 2 + 5 files changed, 625 insertions(+), 1 deletion(-) create mode 100644 extensions/image-paste/index.ts create mode 100644 tests/extensions/image-paste/index.test.ts diff --git a/README.md b/README.md index 16c6b9e6..874a0821 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ OpenPI 把成熟 Coding Agent 的工作习惯做成 Pi-native 能力,但不复 | 编排 | `pipeline` / `parallel`、结构化输出、Result Handoff、Operator、Safe Replay、派生 Graph | | 连续性 | Tasks、Goal、Plan Mode、Context Pivot、Session Browser、Session-scoped Cron | | 自定义 Agent | `explorer` / `implementer` / `reviewer` / `advisor`,支持全局与项目角色文件、独立模型与 effort | -| 终端工作台 | 自定义 Footer 与任务栏、运行状态、紧凑 Tool Result、Next-action Suggestion、Git / PR 信号 | +| 终端工作台 | 自定义 Footer 与任务栏、运行状态、紧凑 Tool Result、图片粘贴占位符、Next-action Suggestion、Git / PR 信号 | | 快捷工作流 | `/btw` 旁路提问(TUI)、`/lg` 浏览 Diff(TUI)、`/pr` 查 PR、`/copy-all`、`fd`、`rg`、只读 Git 工具 | | 人类决策 | `ask_user` 草稿与最终复核、parent-only `human_handoff`、Plan Ready 实施门禁 | | 统一配置 | `/openpi-setup` 管理 OpenPI 自有模型、并发、Footer、输出密度与 Post-edit 偏好 | diff --git a/extensions/image-paste/index.ts b/extensions/image-paste/index.ts new file mode 100644 index 00000000..9c4edea1 --- /dev/null +++ b/extensions/image-paste/index.ts @@ -0,0 +1,430 @@ +import { readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, extname, resolve } from "node:path"; +import type { ImageContent } from "@earendil-works/pi-ai"; +import type { + ExtensionAPI, + ExtensionContext, + KeybindingsManager, +} from "@earendil-works/pi-coding-agent"; +import { type EditorComponent, matchesKey } from "@earendil-works/pi-tui"; +import { + BelowEditorNavigationEditor, + BelowEditorStripState, +} from "../shared/below-editor-navigation.ts"; +import { + registerEditorLayer, + removeEditorLayer, +} from "../shared/editor-layers.ts"; + +const IMAGE_PLACEHOLDER = /\[Image #(\d+)\]/g; +const PI_CLIPBOARD_IMAGE = + /^pi-clipboard-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.(gif|jpe?g|png|webp)$/i; +const LEFT_INPUT = "\u001b[D"; +const RIGHT_INPUT = "\u001b[C"; + +interface Attachment { + readonly id: number; + readonly placeholder: string; + readonly path: string; + readonly mimeType: string; +} + +interface Submission { + readonly id: number; + readonly text: string; + readonly attachments: readonly Attachment[]; +} + +function normalizedPath(path: string) { + const normalized = resolve(path); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + +function mimeTypeForPath(path: string) { + switch (extname(path).toLowerCase()) { + case ".gif": + return "image/gif"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".png": + return "image/png"; + case ".webp": + return "image/webp"; + default: + return undefined; + } +} + +function isPiClipboardImage(path: string) { + if (normalizedPath(dirname(path)) !== normalizedPath(tmpdir())) return false; + if (!PI_CLIPBOARD_IMAGE.test(basename(path))) return false; + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +function removeTemporaryImage(path: string) { + try { + rmSync(path, { force: true }); + } catch { + // Cleanup is best effort; never turn an editor action into a crash. + } +} + +function placeholderOccurrences(text: string) { + return [...text.matchAll(IMAGE_PLACEHOLDER)].map((match) => ({ + id: Number(match[1]), + start: match.index, + end: match.index + match[0].length, + })); +} + +export class ImageAttachmentStore { + private nextAttachmentId = 1; + private nextSubmissionId = 1; + private readonly draft = new Map(); + private readonly pending = new Map(); + + get hasDraft() { + return this.draft.size > 0; + } + + attachClipboardPath(path: string, editorText: string) { + const mimeType = mimeTypeForPath(path); + if (!mimeType || !isPiClipboardImage(path)) return undefined; + + let placeholder = `[Image #${this.nextAttachmentId}]`; + while (editorText.includes(placeholder)) { + this.nextAttachmentId += 1; + placeholder = `[Image #${this.nextAttachmentId}]`; + } + const attachment = { + id: this.nextAttachmentId, + placeholder, + path, + mimeType, + } satisfies Attachment; + this.nextAttachmentId += 1; + this.draft.set(attachment.id, attachment); + return placeholder; + } + + reconcileDraft(text: string) { + for (const [id, attachment] of this.draft) { + if (text.includes(attachment.placeholder)) continue; + this.draft.delete(id); + removeTemporaryImage(attachment.path); + } + if (this.draft.size === 0) this.nextAttachmentId = 1; + } + + beginSubmission(text: string) { + this.reconcileDraft(text); + const attachments = [...this.draft.values()]; + if (attachments.length === 0) return undefined; + this.draft.clear(); + this.nextAttachmentId = 1; + const submission = { + id: this.nextSubmissionId, + text, + attachments, + } satisfies Submission; + this.nextSubmissionId += 1; + this.pending.set(submission.id, submission); + return submission.id; + } + + finishSubmission(id: number) { + const submission = this.pending.get(id); + if (!submission) return; + this.pending.delete(id); + for (const attachment of submission.attachments) { + removeTemporaryImage(attachment.path); + } + } + + consumeSubmission(text: string) { + const submission = + [...this.pending.values()].find((candidate) => candidate.text === text) ?? + [...this.pending.values()].find((candidate) => + candidate.attachments.every((attachment) => + text.includes(attachment.placeholder), + ), + ); + if (!submission) return undefined; + this.pending.delete(submission.id); + + const ordered = [...submission.attachments].sort( + (left, right) => + text.indexOf(left.placeholder) - text.indexOf(right.placeholder), + ); + const images: ImageContent[] = []; + const failures: string[] = []; + let transformedText = text; + for (const attachment of ordered) { + try { + images.push({ + type: "image", + data: readFileSync(attachment.path).toString("base64"), + mimeType: attachment.mimeType, + }); + transformedText = transformedText.replaceAll( + attachment.placeholder, + "", + ); + } catch { + failures.push(attachment.placeholder); + } finally { + removeTemporaryImage(attachment.path); + } + } + return { text: transformedText, images, failures }; + } + + attachmentHit( + text: string, + cursor: number, + direction: "backward" | "forward", + ) { + for (const occurrence of placeholderOccurrences(text)) { + if (!this.draft.has(occurrence.id)) continue; + if ( + direction === "backward" + ? cursor > occurrence.start && cursor <= occurrence.end + : cursor >= occurrence.start && cursor < occurrence.end + ) { + return occurrence; + } + } + return undefined; + } + + cleanup() { + for (const attachment of this.draft.values()) { + removeTemporaryImage(attachment.path); + } + for (const submission of this.pending.values()) { + for (const attachment of submission.attachments) { + removeTemporaryImage(attachment.path); + } + } + this.draft.clear(); + this.pending.clear(); + this.nextAttachmentId = 1; + this.nextSubmissionId = 1; + } +} + +interface CursorEditor extends EditorComponent { + getCursor(): { line: number; col: number } | undefined; +} + +function cursorOffset(editor: EditorComponent) { + const cursor = ( + editor as EditorComponent & Partial + ).getCursor?.(); + if (!cursor) return undefined; + const lines = editor.getText().split("\n"); + let offset = 0; + for (let line = 0; line < cursor.line; line += 1) { + offset += (lines[line]?.length ?? 0) + 1; + } + return offset + cursor.col; +} + +export class ImageAttachmentEditor extends BelowEditorNavigationEditor { + private readonly editor: EditorComponent; + private readonly editorKeybindings: KeybindingsManager; + private readonly attachments: ImageAttachmentStore; + private downstreamChange?: (text: string) => void; + private downstreamSubmit?: (text: string) => void; + private settingText = false; + + constructor( + base: EditorComponent, + keybindings: KeybindingsManager, + attachments: ImageAttachmentStore, + ) { + super( + base, + keybindings, + new BelowEditorStripState(), + () => false, + () => undefined, + () => undefined, + ); + this.editor = base; + this.editorKeybindings = keybindings; + this.attachments = attachments; + this.onChange = super.onChange; + this.onSubmit = super.onSubmit; + } + + override get onChange() { + return this.downstreamChange; + } + + override set onChange(value: ((text: string) => void) | undefined) { + this.downstreamChange = value; + super.onChange = (text) => { + if (!this.settingText && text.length === 0 && this.attachments.hasDraft) { + queueMicrotask(() => this.attachments.reconcileDraft(this.getText())); + } else { + this.attachments.reconcileDraft(text); + } + this.downstreamChange?.(text); + }; + } + + override get onSubmit() { + return this.downstreamSubmit; + } + + override set onSubmit(value: ((text: string) => void) | undefined) { + this.downstreamSubmit = value; + super.onSubmit = value + ? (text) => { + const submissionId = this.attachments.beginSubmission(text); + let outcome: unknown; + try { + outcome = value(text); + } catch (error) { + if (submissionId !== undefined) { + this.attachments.finishSubmission(submissionId); + } + throw error; + } + if (submissionId !== undefined) { + void Promise.resolve(outcome).then( + () => this.attachments.finishSubmission(submissionId), + () => this.attachments.finishSubmission(submissionId), + ); + } + } + : undefined; + } + + override setText(text: string) { + this.settingText = true; + try { + super.setText(text); + } finally { + this.settingText = false; + } + this.attachments.reconcileDraft(text); + } + + override insertTextAtCursor(text: string) { + const placeholder = this.attachments.attachClipboardPath( + text, + this.getText(), + ); + super.insertTextAtCursor(placeholder ?? text); + } + + private deleteAttachment(data: string, direction: "backward" | "forward") { + const text = this.getText(); + const cursor = cursorOffset(this.editor); + if (cursor === undefined) return false; + const hit = this.attachments.attachmentHit(text, cursor, direction); + if (!hit) return false; + + // Keep Pi's editor state intact: setText() would clear its native long-paste + // registry. Move to one edge, then replay the already-matched deletion + // action so one user keypress removes the whole image token without + // disturbing ordinary paste markers or autocomplete state. + const navigationInput = direction === "backward" ? RIGHT_INPUT : LEFT_INPUT; + const navigationSteps = + direction === "backward" ? hit.end - cursor : cursor - hit.start; + for (let step = 0; step < navigationSteps; step += 1) { + this.editor.handleInput(navigationInput); + } + for (let step = hit.start; step < hit.end; step += 1) { + this.editor.handleInput(data); + } + return true; + } + + override handleInput(data: string) { + if ( + (this.editorKeybindings.matches(data, "tui.editor.deleteCharBackward") || + matchesKey(data, "shift+backspace")) && + this.deleteAttachment(data, "backward") + ) { + return; + } + if ( + (this.editorKeybindings.matches(data, "tui.editor.deleteCharForward") || + matchesKey(data, "shift+delete")) && + this.deleteAttachment(data, "forward") + ) { + return; + } + super.handleInput(data); + } +} + +export function transformImageAttachmentInput( + attachments: ImageAttachmentStore, + event: { + text: string; + images?: ImageContent[]; + source: string; + }, +) { + if (event.source !== "interactive") return undefined; + const consumed = attachments.consumeSubmission(event.text); + if (!consumed) return undefined; + return { + text: consumed.text, + images: [...(event.images ?? []), ...consumed.images], + failures: consumed.failures, + }; +} + +function installImagePasteEditor( + pi: ExtensionAPI, + ctx: ExtensionContext, + attachments: ImageAttachmentStore, +) { + if (ctx.mode !== "tui") return; + registerEditorLayer(pi, ctx, { + id: "image-paste", + order: 1_000, + wrap: (base, _tui, _theme, keybindings) => + new ImageAttachmentEditor(base, keybindings, attachments), + }); +} + +export default function imagePaste(pi: ExtensionAPI) { + const attachments = new ImageAttachmentStore(); + + pi.on("session_start", (_event, ctx) => { + installImagePasteEditor(pi, ctx, attachments); + }); + + pi.on("input", (event, ctx) => { + const transformed = transformImageAttachmentInput(attachments, event); + if (!transformed) return { action: "continue" }; + for (const placeholder of transformed.failures) { + ctx.ui.notify( + `${placeholder} could not be read and was not attached`, + "warning", + ); + } + return { + action: "transform", + text: transformed.text, + images: transformed.images, + }; + }); + + pi.on("session_shutdown", () => { + removeEditorLayer(pi, "image-paste"); + attachments.cleanup(); + }); +} diff --git a/extensions/shared/below-editor-navigation.ts b/extensions/shared/below-editor-navigation.ts index 6d27bc4a..adad8779 100644 --- a/extensions/shared/below-editor-navigation.ts +++ b/extensions/shared/below-editor-navigation.ts @@ -61,6 +61,10 @@ interface AppAwareEditor extends EditorComponent { onExtensionShortcut?: (data: string) => boolean; } +interface CursorAwareEditor extends EditorComponent { + getCursor(): { line: number; col: number }; +} + function appAwareEditor(editor: EditorComponent): AppAwareEditor | undefined { const candidate = editor as EditorComponent & Partial; return candidate.actionHandlers instanceof Map @@ -303,6 +307,11 @@ export class BelowEditorNavigationEditor implements EditorComponent, Focusable { return this.base.getExpandedText?.() ?? this.base.getText(); } + getCursor() { + const child = this.base as EditorComponent & Partial; + return child.getCursor?.(); + } + setText(text: string) { this.strip.focused = false; this.base.setText(text); diff --git a/tests/extensions/image-paste/index.test.ts b/tests/extensions/image-paste/index.test.ts new file mode 100644 index 00000000..eb31fcf7 --- /dev/null +++ b/tests/extensions/image-paste/index.test.ts @@ -0,0 +1,183 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { existsSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import type { KeybindingsManager } from "@earendil-works/pi-coding-agent"; +import type { EditorComponent } from "@earendil-works/pi-tui"; +import { + ImageAttachmentEditor, + ImageAttachmentStore, + transformImageAttachmentInput, +} from "../../../extensions/image-paste/index.ts"; + +function temporaryImage(extension: "jpg" | "png", bytes: string) { + const path = join(tmpdir(), `pi-clipboard-${randomUUID()}.${extension}`); + writeFileSync(path, bytes); + return path; +} + +class FakeEditor implements EditorComponent { + focused = false; + text = ""; + cursor = 0; + setTextCalls = 0; + onSubmit?: (text: string) => void; + onChange?: (text: string) => void; + + render() { + return [this.text]; + } + + invalidate() {} + + getText() { + return this.text; + } + + getExpandedText() { + return this.text; + } + + getCursor() { + const before = this.text.slice(0, this.cursor).split("\n"); + return { line: before.length - 1, col: before.at(-1)?.length ?? 0 }; + } + + setText(text: string) { + this.setTextCalls += 1; + this.text = text; + this.cursor = text.length; + this.onChange?.(text); + } + + insertTextAtCursor(text: string) { + this.text = + this.text.slice(0, this.cursor) + text + this.text.slice(this.cursor); + this.cursor += text.length; + this.onChange?.(this.text); + } + + handleInput(data: string) { + if (data === "\u001b[D") this.cursor = Math.max(0, this.cursor - 1); + if (data === "\u001b[C") { + this.cursor = Math.min(this.text.length, this.cursor + 1); + } + if (data === "BACKSPACE" && this.cursor > 0) { + this.text = + this.text.slice(0, this.cursor - 1) + this.text.slice(this.cursor); + this.cursor -= 1; + this.onChange?.(this.text); + } + } + + submit() { + const text = this.text.trim(); + this.text = ""; + this.cursor = 0; + this.onChange?.(""); + this.onSubmit?.(text); + } +} + +const keybindings = { + matches: (data: string, action: string) => + data === "BACKSPACE" && action === "tui.editor.deleteCharBackward", +} as unknown as KeybindingsManager; + +test("clipboard images become compact ordered placeholders and native image content", () => { + const first = temporaryImage("png", "first"); + const second = temporaryImage("jpg", "second"); + const store = new ImageAttachmentStore(); + const base = new FakeEditor(); + const editor = new ImageAttachmentEditor(base, keybindings, store); + + editor.insertTextAtCursor("before "); + editor.insertTextAtCursor(first); + editor.insertTextAtCursor(" between "); + editor.insertTextAtCursor(second); + assert.equal(editor.getText(), "before [Image #1] between [Image #2]"); + + let transformed: ReturnType | undefined; + editor.onSubmit = (text) => { + transformed = transformImageAttachmentInput(store, { + text, + source: "interactive", + }); + }; + base.submit(); + + assert.equal(transformed?.text, "before between "); + assert.deepEqual( + transformed?.images.map(({ data, mimeType }) => ({ data, mimeType })), + [ + { data: Buffer.from("first").toString("base64"), mimeType: "image/png" }, + { + data: Buffer.from("second").toString("base64"), + mimeType: "image/jpeg", + }, + ], + ); + assert.equal(existsSync(first), false); + assert.equal(existsSync(second), false); + + const nextPrompt = temporaryImage("png", "next"); + editor.insertTextAtCursor(nextPrompt); + assert.equal(editor.getText(), "[Image #1]"); + store.cleanup(); + assert.equal(existsSync(nextPrompt), false); +}); + +test("backspace anywhere in an image placeholder removes it atomically", () => { + const path = temporaryImage("png", "image"); + const store = new ImageAttachmentStore(); + const base = new FakeEditor(); + const editor = new ImageAttachmentEditor(base, keybindings, store); + + editor.insertTextAtCursor("left "); + editor.insertTextAtCursor(path); + editor.insertTextAtCursor(" right"); + base.cursor = "left [Image".length; + editor.handleInput("BACKSPACE"); + + assert.equal(editor.getText(), "left right"); + assert.equal(base.cursor, "left ".length); + assert.equal(base.setTextCalls, 0); + assert.equal(existsSync(path), false); +}); + +test("ordinary paths and unregistered placeholder text stay ordinary text", () => { + const store = new ImageAttachmentStore(); + const base = new FakeEditor(); + const editor = new ImageAttachmentEditor(base, keybindings, store); + + editor.insertTextAtCursor("/tmp/example.png [Image #1]"); + assert.equal(editor.getText(), "/tmp/example.png [Image #1]"); + assert.equal( + transformImageAttachmentInput(store, { + text: editor.getText(), + source: "interactive", + }), + undefined, + ); +}); + +test("removing the draft or ending the session cleans temporary images", () => { + const removed = temporaryImage("png", "removed"); + const shutdown = temporaryImage("png", "shutdown"); + const store = new ImageAttachmentStore(); + const base = new FakeEditor(); + const editor = new ImageAttachmentEditor(base, keybindings, store); + + editor.insertTextAtCursor(removed); + editor.setText(""); + assert.equal(existsSync(removed), false); + + editor.insertTextAtCursor(shutdown); + store.cleanup(); + assert.equal(existsSync(shutdown), false); + + rmSync(removed, { force: true }); + rmSync(shutdown, { force: true }); +}); diff --git a/tests/extensions/shared/editor-layers.test.ts b/tests/extensions/shared/editor-layers.test.ts index 7b8530d0..97f6f2fd 100644 --- a/tests/extensions/shared/editor-layers.test.ts +++ b/tests/extensions/shared/editor-layers.test.ts @@ -6,6 +6,7 @@ import type { ExtensionFactory, } from "@earendil-works/pi-coding-agent"; import { createCapabilitiesExtension } from "../../../extensions/capabilities/index.ts"; +import imagePaste from "../../../extensions/image-paste/index.ts"; import subagents from "../../../extensions/subagents/index.ts"; import suggestions from "../../../extensions/suggestions/index.ts"; import workflows from "../../../extensions/workflows/index.ts"; @@ -77,6 +78,7 @@ function editorLifecycleHarness() { ); load(suggestions); load(workflows); + load(imagePaste); const ctx = { cwd: process.cwd(), From 9c7f9ba6e5c9a32d859b69e093a41d72402c30d8 Mon Sep 17 00:00:00 2001 From: onychen <2752845347@qq.com> Date: Sun, 6 Sep 2026 17:22:08 +0800 Subject: [PATCH 2/3] fix(tui): preserve pasted images through submission --- extensions/image-paste/index.ts | 68 ++++++--- tests/extensions/image-paste/index.test.ts | 168 ++++++++++++++++++++- 2 files changed, 216 insertions(+), 20 deletions(-) diff --git a/extensions/image-paste/index.ts b/extensions/image-paste/index.ts index 9c4edea1..fa01c75b 100644 --- a/extensions/image-paste/index.ts +++ b/extensions/image-paste/index.ts @@ -97,29 +97,38 @@ export class ImageAttachmentStore { const mimeType = mimeTypeForPath(path); if (!mimeType || !isPiClipboardImage(path)) return undefined; - let placeholder = `[Image #${this.nextAttachmentId}]`; - while (editorText.includes(placeholder)) { - this.nextAttachmentId += 1; - placeholder = `[Image #${this.nextAttachmentId}]`; - } + let id = this.nextAttachmentId; + while (this.draft.has(id) || editorText.includes(`[Image #${id}]`)) id += 1; + const placeholder = `[Image #${id}]`; const attachment = { - id: this.nextAttachmentId, + id, placeholder, path, mimeType, } satisfies Attachment; - this.nextAttachmentId += 1; + this.nextAttachmentId = id + 1; this.draft.set(attachment.id, attachment); return placeholder; } reconcileDraft(text: string) { + const occurrenceCounts = new Map(); + for (const occurrence of placeholderOccurrences(text)) { + occurrenceCounts.set( + occurrence.id, + (occurrenceCounts.get(occurrence.id) ?? 0) + 1, + ); + } for (const [id, attachment] of this.draft) { - if (text.includes(attachment.placeholder)) continue; + // Placeholder text is user-editable, so only an unambiguous single + // occurrence may retain ownership of a temporary image. Duplicated or + // removed tokens become ordinary text and the image is cleaned up. + if (occurrenceCounts.get(id) === 1) continue; this.draft.delete(id); removeTemporaryImage(attachment.path); } - if (this.draft.size === 0) this.nextAttachmentId = 1; + this.nextAttachmentId = + this.draft.size === 0 ? 1 : Math.max(...this.draft.keys()) + 1; } beginSubmission(text: string) { @@ -148,13 +157,22 @@ export class ImageAttachmentStore { } consumeSubmission(text: string) { - const submission = + let submission = [...this.pending.values()].find((candidate) => candidate.text === text) ?? [...this.pending.values()].find((candidate) => candidate.attachments.every((attachment) => text.includes(attachment.placeholder), ), ); + // Pi's streaming Alt+Enter path clears the editor and calls session.prompt + // directly, bypassing the editor's onSubmit callback. Claim the surviving + // draft here so that path still reaches the native input event boundary. + if (!submission) { + const submissionId = this.beginSubmission(text); + if (submissionId !== undefined) { + submission = this.pending.get(submissionId); + } + } if (!submission) return undefined; this.pending.delete(submission.id); @@ -172,12 +190,12 @@ export class ImageAttachmentStore { data: readFileSync(attachment.path).toString("base64"), mimeType: attachment.mimeType, }); + } catch { + failures.push(attachment.placeholder); transformedText = transformedText.replaceAll( attachment.placeholder, "", ); - } catch { - failures.push(attachment.placeholder); } finally { removeTemporaryImage(attachment.path); } @@ -271,8 +289,12 @@ export class ImageAttachmentEditor extends BelowEditorNavigationEditor { override set onChange(value: ((text: string) => void) | undefined) { this.downstreamChange = value; super.onChange = (text) => { - if (!this.settingText && text.length === 0 && this.attachments.hasDraft) { - queueMicrotask(() => this.attachments.reconcileDraft(this.getText())); + if (this.settingText) { + this.downstreamChange?.(text); + return; + } + if (text.length === 0 && this.attachments.hasDraft) { + setTimeout(() => this.attachments.reconcileDraft(this.getText()), 0); } else { this.attachments.reconcileDraft(text); } @@ -299,9 +321,13 @@ export class ImageAttachmentEditor extends BelowEditorNavigationEditor { throw error; } if (submissionId !== undefined) { - void Promise.resolve(outcome).then( - () => this.attachments.finishSubmission(submissionId), - () => this.attachments.finishSubmission(submissionId), + // A fulfilled Pi submit callback only means that the editor accepted + // the text. The interactive loop may have queued it and emit the + // input event later, so successful settlement is not terminal + // evidence for the attachment. Input consumption owns success + // cleanup; rejection and session shutdown own the other paths. + void Promise.resolve(outcome).catch(() => + this.attachments.finishSubmission(submissionId), ); } } @@ -315,7 +341,13 @@ export class ImageAttachmentEditor extends BelowEditorNavigationEditor { } finally { this.settingText = false; } - this.attachments.reconcileDraft(text); + if (text.length > 0) { + this.attachments.reconcileDraft(text); + } else if (this.attachments.hasDraft) { + // Alt+Enter clears the editor immediately before submitting. Let the + // same-turn submit/input handler claim the draft before reconciling it. + setTimeout(() => this.attachments.reconcileDraft(this.getText()), 0); + } } override insertTextAtCursor(text: string) { diff --git a/tests/extensions/image-paste/index.test.ts b/tests/extensions/image-paste/index.test.ts index eb31fcf7..9b3a5877 100644 --- a/tests/extensions/image-paste/index.test.ts +++ b/tests/extensions/image-paste/index.test.ts @@ -108,7 +108,7 @@ test("clipboard images become compact ordered placeholders and native image cont }; base.submit(); - assert.equal(transformed?.text, "before between "); + assert.equal(transformed?.text, "before [Image #1] between [Image #2]"); assert.deepEqual( transformed?.images.map(({ data, mimeType }) => ({ data, mimeType })), [ @@ -147,6 +147,71 @@ test("backspace anywhere in an image placeholder removes it atomically", () => { assert.equal(existsSync(path), false); }); +test("Alt+Enter paths retain images after Pi clears the editor first", () => { + for (const pathKind of ["idle", "streaming"] as const) { + const path = temporaryImage("png", pathKind); + const store = new ImageAttachmentStore(); + const base = new FakeEditor(); + const editor = new ImageAttachmentEditor(base, keybindings, store); + + editor.insertTextAtCursor("send "); + editor.insertTextAtCursor(path); + const submittedText = editor.getText(); + editor.setText(""); + + let transformed: ReturnType; + if (pathKind === "idle") { + editor.onSubmit = (text) => { + transformed = transformImageAttachmentInput(store, { + text, + source: "interactive", + }); + }; + editor.onSubmit(submittedText); + } else { + transformed = transformImageAttachmentInput(store, { + text: submittedText, + source: "interactive", + }); + } + + assert.equal(transformed?.text, "send [Image #1]"); + assert.equal( + transformed?.images[0]?.data, + Buffer.from(pathKind).toString("base64"), + ); + assert.equal(existsSync(path), false); + } +}); + +test("submitted images survive until Pi emits the delayed input event", async () => { + const path = temporaryImage("png", "delayed"); + const store = new ImageAttachmentStore(); + const base = new FakeEditor(); + const editor = new ImageAttachmentEditor(base, keybindings, store); + + editor.insertTextAtCursor("describe "); + editor.insertTextAtCursor(path); + const submittedText = editor.getText(); + editor.onSubmit = async () => {}; + base.submit(); + + // Pi may queue the text in pendingUserInputs and resolve onSubmit before its + // main loop reaches session.prompt(), which is where the input event fires. + await Promise.resolve(); + const transformed = transformImageAttachmentInput(store, { + text: submittedText, + source: "interactive", + }); + + assert.equal(transformed?.text, "describe [Image #1]"); + assert.equal( + transformed?.images[0]?.data, + Buffer.from("delayed").toString("base64"), + ); + assert.equal(existsSync(path), false); +}); + test("ordinary paths and unregistered placeholder text stay ordinary text", () => { const store = new ImageAttachmentStore(); const base = new FakeEditor(); @@ -163,8 +228,98 @@ test("ordinary paths and unregistered placeholder text stay ordinary text", () = ); }); -test("removing the draft or ending the session cleans temporary images", () => { +test("duplicating a placeholder makes both copies ordinary text", () => { + const path = temporaryImage("png", "image"); + const store = new ImageAttachmentStore(); + const base = new FakeEditor(); + const editor = new ImageAttachmentEditor(base, keybindings, store); + + editor.insertTextAtCursor(path); + editor.insertTextAtCursor(" [Image #1]"); + + assert.equal(editor.getText(), "[Image #1] [Image #1]"); + assert.equal(existsSync(path), false); + assert.equal( + transformImageAttachmentInput(store, { + text: editor.getText(), + source: "interactive", + }), + undefined, + ); +}); + +test("deleting the last image makes its number available to the next paste", () => { + const first = temporaryImage("png", "first"); + const second = temporaryImage("png", "second"); + const third = temporaryImage("png", "third"); + const store = new ImageAttachmentStore(); + const base = new FakeEditor(); + const editor = new ImageAttachmentEditor(base, keybindings, store); + + editor.insertTextAtCursor(first); + editor.insertTextAtCursor(" "); + editor.insertTextAtCursor(second); + assert.equal(editor.getText(), "[Image #1] [Image #2]"); + + base.cursor = editor.getText().length; + editor.handleInput("BACKSPACE"); + assert.equal(editor.getText(), "[Image #1] "); + assert.equal(existsSync(second), false); + + base.cursor = editor.getText().length; + editor.insertTextAtCursor(third); + assert.equal(editor.getText(), "[Image #1] [Image #2]"); + store.cleanup(); +}); + +test("deleting an earlier image does not reorder later image numbers", () => { + const first = temporaryImage("png", "first"); + const second = temporaryImage("png", "second"); + const third = temporaryImage("png", "third"); + const store = new ImageAttachmentStore(); + const base = new FakeEditor(); + const editor = new ImageAttachmentEditor(base, keybindings, store); + + editor.insertTextAtCursor(first); + editor.insertTextAtCursor(" "); + editor.insertTextAtCursor(second); + base.cursor = "[Image #1]".length; + editor.handleInput("BACKSPACE"); + + assert.equal(editor.getText(), " [Image #2]"); + base.cursor = editor.getText().length; + editor.insertTextAtCursor(" "); + editor.insertTextAtCursor(third); + assert.equal(editor.getText(), " [Image #2] [Image #3]"); + store.cleanup(); +}); + +test("a failed image read removes the dangling placeholder", () => { + const path = temporaryImage("png", "image"); + const store = new ImageAttachmentStore(); + const base = new FakeEditor(); + const editor = new ImageAttachmentEditor(base, keybindings, store); + + editor.insertTextAtCursor("before "); + editor.insertTextAtCursor(path); + editor.insertTextAtCursor(" after"); + const text = editor.getText(); + const submissionId = store.beginSubmission(text); + assert.notEqual(submissionId, undefined); + rmSync(path, { force: true }); + + const transformed = transformImageAttachmentInput(store, { + text, + source: "interactive", + }); + assert.equal(transformed?.text, "before after"); + assert.deepEqual(transformed?.images, []); + assert.deepEqual(transformed?.failures, ["[Image #1]"]); +}); + +test("removing the draft or ending the session cleans temporary images", async () => { const removed = temporaryImage("png", "removed"); + const pending = temporaryImage("png", "pending"); const shutdown = temporaryImage("png", "shutdown"); const store = new ImageAttachmentStore(); const base = new FakeEditor(); @@ -172,12 +327,21 @@ test("removing the draft or ending the session cleans temporary images", () => { editor.insertTextAtCursor(removed); editor.setText(""); + await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(existsSync(removed), false); + editor.insertTextAtCursor(pending); + editor.onSubmit = async () => {}; + base.submit(); + await Promise.resolve(); + assert.equal(existsSync(pending), true); + editor.insertTextAtCursor(shutdown); store.cleanup(); + assert.equal(existsSync(pending), false); assert.equal(existsSync(shutdown), false); rmSync(removed, { force: true }); + rmSync(pending, { force: true }); rmSync(shutdown, { force: true }); }); From 868b7cc30c3c2a0f53de568b04034cd7843f44d7 Mon Sep 17 00:00:00 2001 From: onychen <2752845347@qq.com> Date: Mon, 7 Sep 2026 00:29:08 +0800 Subject: [PATCH 3/3] fix(tui): bind image ownership to submissions --- extensions/image-paste/index.ts | 75 ++++++++------ tests/extensions/image-paste/index.test.ts | 112 ++++++++++++++++++++- 2 files changed, 156 insertions(+), 31 deletions(-) diff --git a/extensions/image-paste/index.ts b/extensions/image-paste/index.ts index fa01c75b..f5e926f8 100644 --- a/extensions/image-paste/index.ts +++ b/extensions/image-paste/index.ts @@ -156,24 +156,21 @@ export class ImageAttachmentStore { } } - consumeSubmission(text: string) { - let submission = - [...this.pending.values()].find((candidate) => candidate.text === text) ?? - [...this.pending.values()].find((candidate) => - candidate.attachments.every((attachment) => - text.includes(attachment.placeholder), - ), - ); - // Pi's streaming Alt+Enter path clears the editor and calls session.prompt - // directly, bypassing the editor's onSubmit callback. Claim the surviving - // draft here so that path still reaches the native input event boundary. - if (!submission) { - const submissionId = this.beginSubmission(text); - if (submissionId !== undefined) { - submission = this.pending.get(submissionId); + discardPendingSubmissions() { + for (const submission of this.pending.values()) { + for (const attachment of submission.attachments) { + removeTemporaryImage(attachment.path); } } - if (!submission) return undefined; + this.pending.clear(); + } + + consumeSubmission(text: string) { + // Pi preserves interactive submission order. Consume that lifecycle-owned + // identity instead of letting repeated placeholder text select any older + // pending attachment. + const submission = this.pending.values().next().value; + if (!submission || submission.text !== text) return undefined; this.pending.delete(submission.id); const ordered = [...submission.attachments].sort( @@ -225,13 +222,8 @@ export class ImageAttachmentStore { for (const attachment of this.draft.values()) { removeTemporaryImage(attachment.path); } - for (const submission of this.pending.values()) { - for (const attachment of submission.attachments) { - removeTemporaryImage(attachment.path); - } - } this.draft.clear(); - this.pending.clear(); + this.discardPendingSubmissions(); this.nextAttachmentId = 1; this.nextSubmissionId = 1; } @@ -260,6 +252,7 @@ export class ImageAttachmentEditor extends BelowEditorNavigationEditor { private readonly attachments: ImageAttachmentStore; private downstreamChange?: (text: string) => void; private downstreamSubmit?: (text: string) => void; + private preparedSubmissionId?: number; private settingText = false; constructor( @@ -310,7 +303,8 @@ export class ImageAttachmentEditor extends BelowEditorNavigationEditor { this.downstreamSubmit = value; super.onSubmit = value ? (text) => { - const submissionId = this.attachments.beginSubmission(text); + const submissionId = + this.preparedSubmissionId ?? this.attachments.beginSubmission(text); let outcome: unknown; try { outcome = value(text); @@ -344,8 +338,6 @@ export class ImageAttachmentEditor extends BelowEditorNavigationEditor { if (text.length > 0) { this.attachments.reconcileDraft(text); } else if (this.attachments.hasDraft) { - // Alt+Enter clears the editor immediately before submitting. Let the - // same-turn submit/input handler claim the draft before reconciling it. setTimeout(() => this.attachments.reconcileDraft(this.getText()), 0); } } @@ -382,6 +374,23 @@ export class ImageAttachmentEditor extends BelowEditorNavigationEditor { } override handleInput(data: string) { + if ( + this.attachments.hasDraft && + this.editorKeybindings.matches(data, "app.message.followUp") + ) { + // Alt+Enter reaches this editor before Pi clears it, but its streaming + // path bypasses onSubmit. Move ownership at the key action boundary. + const submissionId = this.attachments.beginSubmission( + this.getText().trim(), + ); + this.preparedSubmissionId = submissionId; + try { + super.handleInput(data); + } finally { + this.preparedSubmissionId = undefined; + } + return; + } if ( (this.editorKeybindings.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) && @@ -432,9 +441,10 @@ function installImagePasteEditor( }); } -export default function imagePaste(pi: ExtensionAPI) { - const attachments = new ImageAttachmentStore(); - +export default function imagePaste( + pi: ExtensionAPI, + attachments = new ImageAttachmentStore(), +) { pi.on("session_start", (_event, ctx) => { installImagePasteEditor(pi, ctx, attachments); }); @@ -455,6 +465,15 @@ export default function imagePaste(pi: ExtensionAPI) { }; }); + pi.on("session_compact", (event) => { + if (event.willRetry) { + // InteractiveMode flushes retry-bound compaction messages through + // steer()/followUp(), which bypasses the input event. Those submissions + // therefore cannot retain attachment ownership. + attachments.discardPendingSubmissions(); + } + }); + pi.on("session_shutdown", () => { removeEditorLayer(pi, "image-paste"); attachments.cleanup(); diff --git a/tests/extensions/image-paste/index.test.ts b/tests/extensions/image-paste/index.test.ts index 9b3a5877..30a78091 100644 --- a/tests/extensions/image-paste/index.test.ts +++ b/tests/extensions/image-paste/index.test.ts @@ -4,9 +4,13 @@ import { existsSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import type { KeybindingsManager } from "@earendil-works/pi-coding-agent"; +import type { + ExtensionAPI, + ExtensionContext, + KeybindingsManager, +} from "@earendil-works/pi-coding-agent"; import type { EditorComponent } from "@earendil-works/pi-tui"; -import { +import imagePaste, { ImageAttachmentEditor, ImageAttachmentStore, transformImageAttachmentInput, @@ -83,9 +87,37 @@ class FakeEditor implements EditorComponent { const keybindings = { matches: (data: string, action: string) => - data === "BACKSPACE" && action === "tui.editor.deleteCharBackward", + (data === "BACKSPACE" && action === "tui.editor.deleteCharBackward") || + (data === "ALT_ENTER" && action === "app.message.followUp"), } as unknown as KeybindingsManager; +type Handler = (event: unknown, ctx: ExtensionContext) => unknown; + +function imagePasteHarness(attachments: ImageAttachmentStore) { + const handlers = new Map(); + const pi = { + on(event: string, handler: Handler) { + handlers.set(event, [...(handlers.get(event) ?? []), handler]); + }, + } as unknown as ExtensionAPI; + const ctx = { + ui: { + notify() {}, + }, + } as unknown as ExtensionContext; + imagePaste(pi, attachments); + + return { + async emit(event: string, value: unknown) { + let result: unknown; + for (const handler of handlers.get(event) ?? []) { + result = await handler(value, ctx); + } + return result; + }, + }; +} + test("clipboard images become compact ordered placeholders and native image content", () => { const first = temporaryImage("png", "first"); const second = temporaryImage("jpg", "second"); @@ -157,6 +189,7 @@ test("Alt+Enter paths retain images after Pi clears the editor first", () => { editor.insertTextAtCursor("send "); editor.insertTextAtCursor(path); const submittedText = editor.getText(); + editor.handleInput("ALT_ENTER"); editor.setText(""); let transformed: ReturnType; @@ -212,6 +245,79 @@ test("submitted images survive until Pi emits the delayed input event", async () assert.equal(existsSync(path), false); }); +test("a compaction retry discards attachment ownership before later input", async () => { + const path = temporaryImage("png", "compaction"); + const store = new ImageAttachmentStore(); + const harness = imagePasteHarness(store); + const base = new FakeEditor(); + const editor = new ImageAttachmentEditor(base, keybindings, store); + + editor.insertTextAtCursor(path); + editor.onSubmit = async () => {}; + base.submit(); + + // Pi's compaction retry path sends the queued text with steer()/followUp(), + // bypassing the input event that would normally consume this submission. + await harness.emit("session_compact", { willRetry: true }); + const transformed = await harness.emit("input", { + text: "[Image #1]", + source: "interactive", + }); + + assert.deepEqual(transformed, { action: "continue" }); + assert.equal(existsSync(path), false); +}); + +test("placeholder text cannot select a different pending submission", () => { + const path = temporaryImage("png", "pending"); + const store = new ImageAttachmentStore(); + const base = new FakeEditor(); + const editor = new ImageAttachmentEditor(base, keybindings, store); + + editor.insertTextAtCursor("describe "); + editor.insertTextAtCursor(path); + editor.onSubmit = async () => {}; + base.submit(); + + const transformed = transformImageAttachmentInput(store, { + text: "Explain the literal token [Image #1]", + source: "interactive", + }); + + assert.equal(transformed, undefined); + assert.equal(existsSync(path), true); + store.cleanup(); + assert.equal(existsSync(path), false); +}); + +test("streaming Alt+Enter survives an asynchronous preceding input handler", async () => { + const path = temporaryImage("png", "async-handler"); + const store = new ImageAttachmentStore(); + const base = new FakeEditor(); + const editor = new ImageAttachmentEditor(base, keybindings, store); + + editor.insertTextAtCursor("send "); + editor.insertTextAtCursor(path); + const submittedText = editor.getText(); + editor.handleInput("ALT_ENTER"); + editor.setText(""); + + // ExtensionRunner awaits input handlers in registration order. An earlier + // asynchronous handler must not let editor cleanup win the race. + await new Promise((resolve) => setTimeout(resolve, 10)); + const transformed = transformImageAttachmentInput(store, { + text: submittedText, + source: "interactive", + }); + + assert.equal(transformed?.text, "send [Image #1]"); + assert.equal( + transformed?.images[0]?.data, + Buffer.from("async-handler").toString("base64"), + ); + assert.equal(existsSync(path), false); +}); + test("ordinary paths and unregistered placeholder text stay ordinary text", () => { const store = new ImageAttachmentStore(); const base = new FakeEditor();