diff --git a/README.md b/README.md index 97b8ccea..46fb19cf 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ OpenPI 把成熟 Coding Agent 的工作习惯做成 Pi-native 能力,但不复 | 连续性 | Tasks、Goal、Plan Mode、Context Pivot、Session Browser、Session-scoped Cron | | 自定义 Agent | `explorer` / `implementer` / `reviewer` / `advisor`,支持全局与项目角色文件、独立模型与 effort | | 本地 Web | 工作区与会话管理、模型预选、运行诊断、工具证据与持久化轨迹检查;与终端 Session 独立 | -| 终端工作台 | 自定义 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..214da422 --- /dev/null +++ b/extensions/image-paste/index.ts @@ -0,0 +1,541 @@ +import { statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, extname, resolve } from "node:path"; +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; +/** + * Locate clipboard image paths inside already-submitted text. + * + * Pasting two images produces adjacent paths with no separator between them, + * so the directory portion is matched as discrete segments that exclude both + * path separators and `:`, and it is lazy rather than greedy. A greedy middle + * absorbs the next path's `C:` or `/tmp` and renders the pair as one + * placeholder; laziness ends each match at the first filename that completes + * it, which is exactly the boundary between two pasted images. + */ +const CLIPBOARD_PATH_IN_TEXT = + /(?:[A-Za-z]:[\\/]|[\\/])(?:[^\\/:"'<>|]+?[\\/])*?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)/gi; +const IMAGE_EXTENSIONS = new Set([".gif", ".jpg", ".jpeg", ".png", ".webp"]); +const LEFT_INPUT = "\u001b[D"; +const RIGHT_INPUT = "\u001b[C"; + +interface Attachment { + readonly id: number; + readonly placeholder: string; + readonly path: string; +} + +function normalizedPath(path: string) { + const normalized = resolve(path); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + +/** + * Directories a Pi clipboard image can legitimately come from. + * + * The local temp directory covers the running host. The generic POSIX and + * Windows temp shapes are also accepted because a transcript is rendered on + * whatever machine reopens the session, which is not always the one that + * pasted the image. An arbitrary project directory is still rejected, so a file + * that merely shares the basename keeps its real path on screen. + */ +const PORTABLE_TEMP_DIRECTORY = + /^(?:[\\/](?:tmp|private[\\/](?:tmp|var[\\/]folders[\\/][^\\/]+[\\/][^\\/]+[\\/]T)|var[\\/]folders[\\/][^\\/]+[\\/][^\\/]+[\\/]T)|[A-Za-z]:[\\/](?:Users[\\/][^\\/]+[\\/]AppData[\\/]Local[\\/]Temp|Windows[\\/]Temp|Temp))$/i; + +function isTemporaryDirectory(directory: string) { + if (normalizedPath(directory) === normalizedPath(tmpdir())) return true; + return PORTABLE_TEMP_DIRECTORY.test(directory.replace(/[\\/]+$/, "")); +} + +/** + * Provenance check shared by attachment tracking and transcript collapsing: + * the file must carry Pi's clipboard name and sit directly in a temp + * directory. Deliberately free of disk access so an already-sent message still + * collapses after the OS has reclaimed the file. + */ +function isPiClipboardPath(path: string) { + // Normalize backslashes so dirname/basename/extname work on POSIX hosts + // when the path was recorded on Windows (transcript portability). + const normalized = path.replaceAll("\\", "/"); + if (!IMAGE_EXTENSIONS.has(extname(normalized).toLowerCase())) return false; + if (!isTemporaryDirectory(dirname(normalized))) return false; + return PI_CLIPBOARD_IMAGE.test(basename(normalized)); +} + +function isPiClipboardImage(path: string) { + if (!isPiClipboardPath(path)) return false; + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +/** + * Spans that must survive verbatim: fenced blocks and inline code are quoted + * source, and a rewritten path there stops being copy-pasteable. + */ +const MARKDOWN_VERBATIM = /```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`/g; + +function verbatimSpans(text: string) { + return [...text.matchAll(MARKDOWN_VERBATIM)].map((match) => ({ + start: match.index, + end: match.index + match[0].length, + })); +} + +/** + * Collapse clipboard image paths back into compact placeholders for display. + * + * Submission deliberately expands placeholders into real paths so the model can + * read the file, which would otherwise push issue #413's long temp paths from + * the editor into the transcript. This runs at render time only and is + * deliberately stateless: numbering follows the order the paths appear in the + * message, so replays, forks and reloaded sessions all render identically + * without any mapping having to survive the submission. + * + * Rewriting is bounded by provenance and by Markdown structure. Only files Pi + * itself wrote into the temp directory are collapsed, so an unrelated path that + * merely shares the basename keeps its full text. Fenced blocks, inline code + * and link/image targets are left untouched, because collapsing a `](...)` + * target would break the very image it points at. + * + * The number can differ from what the editor showed if images were deleted + * mid-draft. Transcript numbering only distinguishes images within one message, + * so that is accepted rather than carried through message metadata. + */ +export function collapseClipboardPaths(text: string) { + const skip = verbatimSpans(text); + let next = 1; + const assigned = new Map(); + return text.replace(CLIPBOARD_PATH_IN_TEXT, (match, offset: number) => { + if (!isPiClipboardPath(match)) return match; + if (skip.some((span) => offset >= span.start && offset < span.end)) { + return match; + } + // `](path)` is a link or image target; replacing it silently breaks the + // reference, so the path stays literal even though it is a clipboard file. + if (text.startsWith("](", Math.max(0, offset - 2)) && offset >= 2) { + return match; + } + // A path repeated in one message keeps a single number: the transcript + // shows the same image, so a second number would imply a second image. + const existing = assigned.get(match); + if (existing) return existing; + const placeholder = `[Image #${next++}]`; + assigned.set(match, placeholder); + return placeholder; + }); +} + +function placeholderOccurrences(text: string) { + return [...text.matchAll(IMAGE_PLACEHOLDER)].map((match) => ({ + id: Number(match[1]), + start: match.index, + end: match.index + match[0].length, + })); +} + +/** + * Replace each placeholder that appears exactly once with its clipboard path. + * Ambiguous tokens stay collapsed so user-edited text is never over-expanded. + */ +function expandWith(text: string, attachments: Iterable) { + const entries = [...attachments]; + if (entries.length === 0) return text; + const occurrenceCounts = new Map(); + for (const occurrence of placeholderOccurrences(text)) { + occurrenceCounts.set( + occurrence.id, + (occurrenceCounts.get(occurrence.id) ?? 0) + 1, + ); + } + let expanded = text; + for (const attachment of entries) { + if (occurrenceCounts.get(attachment.id) !== 1) continue; + expanded = expanded.replaceAll(attachment.placeholder, attachment.path); + } + return expanded; +} + +/** + * Display-only registry mapping compact placeholders to the clipboard paths Pi + * inserted. This mirrors the editor's native long-paste markers: the buffer + * shows `[Image #1]`, submission expands it back to the real path, and the + * message Pi sends is byte-identical to unmodified Pi. + * + * Nothing here owns the temporary file. Pi leaves clipboard images in tmpdir so + * the read tool can still open them later, and that contract is preserved. + */ +export class ImageAttachmentStore { + private nextAttachmentId = 1; + private readonly draft = new Map(); + + get hasDraft() { + return this.draft.size > 0; + } + + attachClipboardPath(path: string, editorText: string) { + if (!isPiClipboardImage(path)) return undefined; + + let id = this.nextAttachmentId; + while (this.draft.has(id) || editorText.includes(`[Image #${id}]`)) id += 1; + const placeholder = `[Image #${id}]`; + this.nextAttachmentId = id + 1; + this.draft.set(id, { id, placeholder, path } satisfies 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 of [...this.draft.keys()]) { + // Placeholder text is user-editable, so only an unambiguous single + // occurrence keeps its mapping. Duplicated or removed tokens degrade to + // ordinary text; the underlying file is left alone either way. + if (occurrenceCounts.get(id) === 1) continue; + this.draft.delete(id); + } + this.nextAttachmentId = + this.draft.size === 0 ? 1 : Math.max(...this.draft.keys()) + 1; + } + + /** + * Expand every unambiguously tracked placeholder back to its clipboard path, + * matching the editor's own `expandPasteMarkers` behaviour at submission time. + * + * This is a pure query: Pi calls getExpandedText() for rendering and status + * as well as for submission, so expansion must never mutate the draft. + * Expanding an already expanded string is a no-op, which keeps the + * getExpandedText() and onSubmit paths safe to combine. + */ + expandPlaceholders(text: string) { + return expandWith(text, this.draft.values()); + } + + /** + * Copy the current mapping so a submission can still expand after the editor + * has cleared the buffer and released the draft. + */ + snapshotDraft() { + return this.draft.size === 0 ? undefined : [...this.draft.values()]; + } + + clearDraft() { + this.draft.clear(); + this.nextAttachmentId = 1; + } + + /** + * Rebuild the mapping from text that already contains real clipboard paths + * and return its collapsed form. + * + * Pi hands expanded text back to the editor on paths this extension does not + * own: handleDequeue() restores queued messages through setText(), and + * history recall replays what addToHistory() stored. Without adopting those + * paths the buffer would show issue #413's long temp path again and the + * tokens would no longer expand on the next submit. + */ + adoptExpandedText(text: string) { + const matches = [...text.matchAll(CLIPBOARD_PATH_IN_TEXT)].filter((match) => + isPiClipboardPath(match[0]), + ); + if (matches.length === 0) return undefined; + + this.clearDraft(); + // Build replacements in reverse document order so earlier indices stay + // valid while we splice. Each occurrence — even of the same path — gets + // its own placeholder identity so that expandWith's single-occurrence + // guard expands every one of them on submission. + const replacements: { start: number; end: number; placeholder: string }[] = + []; + for (const match of matches) { + const path = match[0]; + const id = this.nextAttachmentId++; + const placeholder = `[Image #${id}]`; + this.draft.set(id, { id, placeholder, path } satisfies Attachment); + replacements.push({ + start: match.index, + end: match.index + path.length, + placeholder, + }); + } + replacements.sort((a, b) => b.start - a.start); + let collapsed = text; + for (const { start, end, placeholder } of replacements) { + collapsed = + collapsed.slice(0, start) + placeholder + collapsed.slice(end); + } + return collapsed; + } + + 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() { + this.clearDraft(); + } +} + +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 submittedDraft?: readonly Attachment[]; + 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) { + // submitValue() clears the buffer and reports it here before invoking + // onSubmit, so an empty buffer ends the draft and restarts numbering. + // Keep a snapshot so the submit that caused it can still expand. + if (text.length === 0) { + this.submittedDraft = this.attachments.snapshotDraft(); + this.attachments.clearDraft(); + } else { + this.submittedDraft = undefined; + // History recall uses setTextInternal (bypasses setText) and only + // fires onChange. When there is no draft but the text contains + // clipboard paths, adopt them so the buffer shows compact tokens. + const adopted = + !this.attachments.hasDraft && + this.attachments.adoptExpandedText(text); + if (adopted) { + this.settingText = true; + try { + super.setText(adopted); + } finally { + this.settingText = false; + } + } 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) => { + // Plain Enter reaches here after submitValue() already cleared the + // buffer, so expand against the snapshot taken at that moment. Paths + // that read getExpandedText() first pass real paths in, and expanding + // an already expanded string is a no-op. + const snapshot = this.submittedDraft; + this.submittedDraft = undefined; + value( + snapshot + ? expandWith(text, snapshot) + : this.attachments.expandPlaceholders(text), + ); + } + : undefined; + } + + /** + * Pi reads the submitted text through this seam before it picks a delivery + * path -- handleFollowUp() calls it ahead of prompt(), queueCompactionMessage() + * and onSubmit alike. Expanding here is what keeps every Alt+Enter branch + * from shipping a bare `[Image #1]`. + */ + override getExpandedText() { + const base = super.getExpandedText?.() ?? this.getText(); + return this.attachments.expandPlaceholders(base); + } + + override setText(text: string) { + // Pi restores queued messages and history entries as expanded paths. Adopt + // them so the buffer shows compact tokens and the next submit can expand + // again; ordinary text falls through to plain reconciliation. + const adopted = + text.length > 0 ? this.attachments.adoptExpandedText(text) : undefined; + const next = adopted ?? text; + this.settingText = true; + try { + super.setText(next); + } finally { + this.settingText = false; + } + if (adopted !== undefined) return; + if (next.length > 0) { + this.attachments.reconcileDraft(next); + } else { + this.attachments.clearDraft(); + } + } + + 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); + } +} + +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, + attachments = new ImageAttachmentStore(), +) { + // Rendering-only counterpart to submission expansion: the model still + // receives real paths, while the transcript keeps the compact placeholders + // that issue #413 asked for. Restricted to user messages so paths the + // assistant legitimately quotes are left untouched. + pi.registerMarkdownTransformer((markdown, context) => + context.messageType === "user" + ? collapseClipboardPaths(markdown) + : markdown, + ); + + pi.on("session_start", (_event, ctx) => { + installImagePasteEditor(pi, ctx, attachments); + }); + + 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..8c509c8a --- /dev/null +++ b/tests/extensions/image-paste/index.test.ts @@ -0,0 +1,581 @@ +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 { + collapseClipboardPaths, + ImageAttachmentEditor, + ImageAttachmentStore, +} from "../../../extensions/image-paste/index.ts"; + +const created: string[] = []; + +function temporaryImage(extension: "jpg" | "png", bytes: string) { + const path = join(tmpdir(), `pi-clipboard-${randomUUID()}.${extension}`); + writeFileSync(path, bytes); + created.push(path); + return path; +} + +test.after(() => { + for (const path of created) rmSync(path, { force: true }); +}); + +class FakeEditor implements EditorComponent { + focused = false; + text = ""; + cursor = 0; + setTextCalls = 0; + history: string[] = []; + 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); + } + + addToHistory(text: string) { + this.history.push(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; + +function harness() { + const store = new ImageAttachmentStore(); + const base = new FakeEditor(); + const editor = new ImageAttachmentEditor(base, keybindings, store); + const submitted: string[] = []; + editor.onSubmit = (text) => submitted.push(text); + return { store, base, editor, submitted }; +} + +/** + * Mirrors Pi's InteractiveMode.handleFollowUp(): it reads the text through + * getExpandedText() *before* choosing a delivery path, and only the idle branch + * reaches onSubmit. The streaming and compaction branches hand the text to + * prompt()/queueCompactionMessage() directly. + */ +function handleFollowUp( + editor: ImageAttachmentEditor, + branch: "idle" | "streaming" | "compacting", +) { + const text = editor.getExpandedText().trim(); + if (!text) return undefined; + if (branch === "idle") { + editor.setText(""); + editor.onSubmit?.(text); + return text; + } + editor.setText(""); + return text; +} + +test("clipboard paths display as compact placeholders while editing", () => { + const first = temporaryImage("png", "first"); + const second = temporaryImage("jpg", "second"); + const { editor } = harness(); + + editor.insertTextAtCursor("before "); + editor.insertTextAtCursor(first); + editor.insertTextAtCursor(" between "); + editor.insertTextAtCursor(second); + + assert.equal(editor.getText(), "before [Image #1] between [Image #2]"); +}); + +test("submission expands placeholders back to the original clipboard paths", () => { + const first = temporaryImage("png", "first"); + const second = temporaryImage("jpg", "second"); + const { base, editor, submitted } = harness(); + + editor.insertTextAtCursor("before "); + editor.insertTextAtCursor(first); + editor.insertTextAtCursor(" between "); + editor.insertTextAtCursor(second); + base.submit(); + + // Pi's own contract: the message text carries real paths so the read tool can + // open them. The placeholder is a display concern and never leaves the editor. + assert.equal(submitted[0], `before ${first} between ${second}`); +}); + +test("submitted clipboard files are left on disk for the read tool", () => { + const path = temporaryImage("png", "kept"); + const { base, editor } = harness(); + + editor.insertTextAtCursor(path); + base.submit(); + + // Pi never deletes clipboard images; preserving that keeps the path valid + // for later turns, including after a compaction retry. + assert.equal(existsSync(path), true); +}); + +test("backspace anywhere in an image placeholder removes it atomically", () => { + const path = temporaryImage("png", "image"); + const { base, editor } = harness(); + + 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); +}); + +test("a removed placeholder is not expanded on submission", () => { + const path = temporaryImage("png", "image"); + const { base, editor, submitted } = harness(); + + editor.insertTextAtCursor("keep "); + editor.insertTextAtCursor(path); + base.cursor = editor.getText().length; + editor.handleInput("BACKSPACE"); + base.submit(); + + assert.equal(submitted[0], "keep"); + assert.equal(existsSync(path), true); +}); + +test("ordinary paths and unregistered placeholder text stay ordinary text", () => { + const { base, editor, submitted } = harness(); + + editor.insertTextAtCursor("/tmp/example.png [Image #1]"); + assert.equal(editor.getText(), "/tmp/example.png [Image #1]"); + base.submit(); + + assert.equal(submitted[0], "/tmp/example.png [Image #1]"); +}); + +test("duplicating a placeholder makes both copies ordinary text", () => { + const path = temporaryImage("png", "image"); + const { base, editor, submitted } = harness(); + + editor.insertTextAtCursor(path); + editor.insertTextAtCursor(" [Image #1]"); + assert.equal(editor.getText(), "[Image #1] [Image #1]"); + base.submit(); + + // An ambiguous token cannot own a path, so neither copy expands. + assert.equal(submitted[0], "[Image #1] [Image #1]"); + assert.equal(existsSync(path), true); +}); + +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 { base, editor, submitted } = harness(); + + 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] "); + + base.cursor = editor.getText().length; + editor.insertTextAtCursor(third); + assert.equal(editor.getText(), "[Image #1] [Image #2]"); + + base.submit(); + assert.equal(submitted[0], `${first} ${third}`); +}); + +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 { base, editor, submitted } = harness(); + + 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]"); + + base.submit(); + assert.equal(submitted[0], `${second} ${third}`); +}); + +test("consecutive submissions do not leak placeholders across drafts", () => { + const first = temporaryImage("png", "first"); + const second = temporaryImage("png", "second"); + const { base, editor, submitted } = harness(); + + editor.insertTextAtCursor(first); + base.submit(); + editor.insertTextAtCursor(second); + assert.equal(editor.getText(), "[Image #1]"); + base.submit(); + + assert.equal(submitted[0], first); + assert.equal(submitted[1], second); +}); + +test("clearing the editor drops placeholder ownership", () => { + const path = temporaryImage("png", "cleared"); + const { editor, base, submitted } = harness(); + + editor.insertTextAtCursor(path); + editor.setText(""); + editor.setText("[Image #1]"); + base.submit(); + + // The token no longer maps to anything, so it is submitted verbatim. + assert.equal(submitted[0], "[Image #1]"); + assert.equal(existsSync(path), true); +}); + +test("Alt+Enter expands paths on every followUp branch", () => { + // Pi reads getExpandedText() before it picks a delivery path, and only the + // idle branch reaches onSubmit. All three must carry real paths. + for (const branch of ["idle", "streaming", "compacting"] as const) { + const path = temporaryImage("png", branch); + const { editor } = harness(); + + editor.insertTextAtCursor("describe "); + editor.insertTextAtCursor(path); + assert.equal(editor.getText(), "describe [Image #1]"); + + const delivered = handleFollowUp(editor, branch); + assert.equal(delivered, `describe ${path}`); + } +}); + +test("Alt+Enter on the idle branch does not double expand via onSubmit", () => { + const path = temporaryImage("png", "idle-once"); + const { editor, submitted } = harness(); + + editor.insertTextAtCursor(path); + handleFollowUp(editor, "idle"); + + // setText("") clears the mapping before onSubmit runs, and the text is + // already expanded, so the downstream callback must see exactly one path. + assert.equal(submitted.length, 1); + assert.equal(submitted[0], path); +}); + +test("getExpandedText does not mutate the draft", () => { + const path = temporaryImage("png", "pure"); + const { editor, base, submitted } = harness(); + + editor.insertTextAtCursor(path); + assert.equal(editor.getExpandedText(), path); + assert.equal(editor.getExpandedText(), path); + // Rendering and status reads must leave the collapsed buffer intact. + assert.equal(editor.getText(), "[Image #1]"); + base.submit(); + assert.equal(submitted[0], path); +}); + +test("getExpandedText leaves ambiguous placeholders collapsed", () => { + const path = temporaryImage("png", "ambiguous"); + const { editor } = harness(); + + editor.insertTextAtCursor(path); + editor.insertTextAtCursor(" [Image #1]"); + + assert.equal(editor.getExpandedText(), "[Image #1] [Image #1]"); +}); + +test("the transcript renders submitted clipboard paths as placeholders", () => { + const path = temporaryImage("png", "transcript"); + + assert.equal( + collapseClipboardPaths(`${path} can you see this image?`), + "[Image #1] can you see this image?", + ); +}); + +test("transcript numbering follows the order paths appear", () => { + const first = temporaryImage("png", "first"); + const second = temporaryImage("jpg", "second"); + + assert.equal( + collapseClipboardPaths(`before ${first} between ${second} after`), + "before [Image #1] between [Image #2] after", + ); +}); + +test("adjacent pasted paths collapse into separate placeholders", () => { + // Pasting images back to back leaves no separator between the paths, so a + // greedy directory match would absorb the next path and show one image. + const first = temporaryImage("png", "adjacent-first"); + const second = temporaryImage("jpg", "adjacent-second"); + const third = temporaryImage("png", "adjacent-third"); + + assert.equal( + collapseClipboardPaths(`${first}${second}`), + "[Image #1][Image #2]", + ); + assert.equal( + collapseClipboardPaths(`${first}${second}${third}`), + "[Image #1][Image #2][Image #3]", + ); + assert.equal( + collapseClipboardPaths(`${first}${second}can you see these?`), + "[Image #1][Image #2]can you see these?", + ); +}); + +test("adjacent posix paths collapse into separate placeholders", () => { + // The Windows boundary is a drive letter, the POSIX one is a bare slash; + // both have to end the previous match rather than extend it. + const first = `/tmp/pi-clipboard-${randomUUID()}.png`; + const second = `/tmp/pi-clipboard-${randomUUID()}.png`; + + assert.equal( + collapseClipboardPaths(`${first}${second}`), + "[Image #1][Image #2]", + ); +}); + +test("a path repeated in one message keeps a single transcript number", () => { + const path = temporaryImage("png", "repeated"); + + assert.equal( + collapseClipboardPaths(`${path} and again ${path}`), + "[Image #1] and again [Image #1]", + ); +}); + +test("a submitted draft round-trips back to the placeholders that were typed", () => { + const path = temporaryImage("png", "round-trip"); + const { editor, base, submitted } = harness(); + + editor.insertTextAtCursor(path); + editor.insertTextAtCursor(" can you see this image?"); + const displayed = editor.getText(); + base.submit(); + + // The model receives the real path, the transcript shows what was typed. + assert.equal(submitted[0], `${path} can you see this image?`); + assert.equal(collapseClipboardPaths(submitted[0]), displayed); +}); + +test("two images pasted back to back round-trip through submission", () => { + const first = temporaryImage("png", "pair-first"); + const second = temporaryImage("jpg", "pair-second"); + const { editor, base, submitted } = harness(); + + editor.insertTextAtCursor(first); + editor.insertTextAtCursor(second); + const displayed = editor.getText(); + assert.equal(displayed, "[Image #1][Image #2]"); + base.submit(); + + assert.equal(submitted[0], `${first}${second}`); + assert.equal(collapseClipboardPaths(submitted[0]), displayed); +}); + +test("transcript collapsing leaves ordinary paths and other temp files alone", () => { + const unrelated = join(tmpdir(), "pi-clipboard-notes.txt"); + const ordinary = join(tmpdir(), "screenshot.png"); + const text = `see ${ordinary} and ${unrelated}`; + + assert.equal(collapseClipboardPaths(text), text); +}); + +test("transcript collapsing does not need the file to still exist", () => { + // Rendering runs for reloaded and forked sessions long after the temp file + // may have been cleaned up by the operating system. + const missing = join(tmpdir(), `pi-clipboard-${randomUUID()}.png`); + + assert.equal(collapseClipboardPaths(`look ${missing}`), "look [Image #1]"); +}); + +test("transcript collapsing only claims files Pi wrote to the temp directory", () => { + // A project file that merely shares the clipboard basename is not ours, and + // hiding its real location would misrepresent what the user sent. + const elsewhere = join( + "/home/user/assets", + `pi-clipboard-${randomUUID()}.png`, + ); + + assert.equal(collapseClipboardPaths(elsewhere), elsewhere); +}); + +test("transcript collapsing preserves markdown link and image targets", () => { + const path = temporaryImage("png", "target"); + + assert.equal( + collapseClipboardPaths(`![diagram](${path})`), + `![diagram](${path})`, + ); +}); + +test("transcript collapsing leaves code spans verbatim", () => { + const path = temporaryImage("png", "code"); + const fenced = `\`\`\`bash\ncp ${path} ./out.png\n\`\`\``; + + assert.equal(collapseClipboardPaths(fenced), fenced); + assert.equal(collapseClipboardPaths(`\`${path}\``), `\`${path}\``); +}); + +test("prose still collapses when the same message contains a code block", () => { + const shown = temporaryImage("png", "prose"); + const quoted = temporaryImage("png", "quoted"); + + assert.equal( + collapseClipboardPaths(`${shown}\n\`\`\`\n${quoted}\n\`\`\``), + `[Image #1]\n\`\`\`\n${quoted}\n\`\`\``, + ); +}); + +test("dequeued messages come back as placeholders and expand again", () => { + // Pi queues getExpandedText() and restores it through setText(), so without + // adopting those paths the editor would show issue #413's long temp path. + const path = temporaryImage("png", "queued"); + const { editor } = harness(); + + editor.insertTextAtCursor(path); + const queued = handleFollowUp(editor, "compacting"); + assert.equal(queued, path); + + editor.setText(queued ?? ""); + + assert.equal(editor.getText(), "[Image #1]"); + assert.equal(editor.getExpandedText(), path); +}); + +test("dequeuing several images renumbers them in order", () => { + const first = temporaryImage("png", "queued-first"); + const second = temporaryImage("jpg", "queued-second"); + const { editor } = harness(); + + editor.setText(`${first}${second} still there?`); + + assert.equal(editor.getText(), "[Image #1][Image #2] still there?"); + assert.equal(editor.getExpandedText(), `${first}${second} still there?`); +}); + +test("history recall restores compact placeholders and expands again", () => { + const path = temporaryImage("png", "history"); + const { base, editor } = harness(); + + // Pi stores getExpandedText() into history — the real path. + editor.addToHistory(`${path} do you see it?`); + assert.deepEqual(base.history, [`${path} do you see it?`]); + + // Up-arrow recall uses setTextInternal which bypasses setText and only + // fires onChange. Simulate that by writing directly to the base editor. + base.text = base.history[0]; + base.onChange?.(base.text); + + assert.equal(editor.getText(), "[Image #1] do you see it?"); + assert.equal(editor.getExpandedText(), `${path} do you see it?`); +}); + +test("repeated identical paths survive the dequeue round trip", () => { + // The same image pasted twice: each occurrence needs its own placeholder + // identity so expandWith's single-occurrence guard expands every one. + const path = temporaryImage("png", "dup"); + const { editor } = harness(); + + editor.setText(`${path} and ${path}`); + + assert.equal(editor.getText(), "[Image #1] and [Image #2]"); + assert.equal(editor.getExpandedText(), `${path} and ${path}`); +}); + +test("repeated identical paths survive history recall", () => { + const path = temporaryImage("png", "dup-recall"); + const { base, editor } = harness(); + + base.text = `${path} ${path} see both?`; + base.onChange?.(base.text); + + assert.equal(editor.getText(), "[Image #1] [Image #2] see both?"); + assert.equal(editor.getExpandedText(), `${path} ${path} see both?`); +}); + +test("transcript collapses Windows paths with spaces in the username", () => { + const uuid = randomUUID(); + const path = `C:\\Users\\Yuka Chen\\AppData\\Local\\Temp\\pi-clipboard-${uuid}.png`; + + assert.equal(collapseClipboardPaths(`look ${path}`), "look [Image #1]"); +}); + +test("adoption handles Windows paths with spaces in the username", () => { + const uuid = randomUUID(); + const winPath = join(tmpdir(), `pi-clipboard-${uuid}.png`); + writeFileSync(winPath, "spacey"); + created.push(winPath); + + const { editor } = harness(); + + editor.setText(`${winPath} here`); + + assert.equal(editor.getText(), "[Image #1] here"); + assert.equal(editor.getExpandedText(), `${winPath} here`); +}); diff --git a/tests/extensions/shared/editor-layers.test.ts b/tests/extensions/shared/editor-layers.test.ts index 7b8530d0..8533ecdb 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"; @@ -62,6 +63,7 @@ function editorLifecycleHarness() { registerCommand() {}, registerMessageRenderer() {}, registerEntryRenderer() {}, + registerMarkdownTransformer() {}, getThinkingLevel: () => "off", sendMessage() {}, appendEntry() {}, @@ -77,6 +79,7 @@ function editorLifecycleHarness() { ); load(suggestions); load(workflows); + load(imagePaste); const ctx = { cwd: process.cwd(),