import { matchesKey, type EditorTheme, type TUI } from "@mariozechner/pi-tui"; import { CustomEditor, type ExtensionAPI, type KeybindingsManager, } from "@mariozechner/pi-coding-agent"; import { hasClipboardImage } from "./clipboard-image.js"; import { readClipboardText } from "./clipboard-text.js"; type NotifyLevel = "info" | "warning" | "error"; type Notify = (message: string, level: NotifyLevel) => void; type EditorWithPatchedPaste = { handlePaste?: (pastedText: string) => void; }; const CLIPBOARD_PASTE_KEYBINDING = "app.clipboard.pasteImage"; const EXTRA_TEXT_PASTE_SHORTCUTS = ["ctrl+v", "alt+v", "ctrl+shift+v", "shift+insert"] as const; class FullTextPasteEditor extends CustomEditor { private readonly tuiRef: TUI; private readonly keybindingsRef: KeybindingsManager; private pasteRequest?: Promise; private warnedClipboardReadFailure = false; constructor( tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, private readonly notify: Notify, ) { super(tui, theme, keybindings); this.tuiRef = tui; this.keybindingsRef = keybindings; ((this as unknown) as EditorWithPatchedPaste).handlePaste = (pastedText: string) => { this.insertExpandedPaste(pastedText); }; } override handleInput(data: string): void { if (this.shouldHandleClipboardPasteShortcut(data)) { void this.pasteFromClipboardShortcut(); return; } super.handleInput(data); } private shouldHandleClipboardPasteShortcut(data: string): boolean { if (this.keybindingsRef.matches(data, CLIPBOARD_PASTE_KEYBINDING)) { return true; } return EXTRA_TEXT_PASTE_SHORTCUTS.some((shortcut) => matchesKey(data, shortcut)); } private async pasteFromClipboardShortcut(): Promise { if (this.pasteRequest) { await this.pasteRequest; return; } this.pasteRequest = (async () => { try { if (await hasClipboardImage()) { this.onPasteImage?.(); return; } const text = await readClipboardText(); if (text !== undefined) { this.insertExpandedPaste(text); this.tuiRef.requestRender(); return; } } catch (error) { console.error("pi-full-text-paste: failed to handle clipboard paste", error); if (!this.warnedClipboardReadFailure) { this.warnedClipboardReadFailure = true; this.notify("Could not read clipboard text directly. Falling back to Pi's default paste behavior.", "warning"); } } finally { this.pasteRequest = undefined; } this.onPasteImage?.(); })(); await this.pasteRequest; } private insertExpandedPaste(rawText: string): void { const text = sanitizePastedText(rawText, this.getLines(), this.getCursor()); if (text.length === 0) { return; } this.insertTextAtCursor(text); } } export default function fullTextPasteExtension(pi: ExtensionAPI) { pi.on("session_start", async (_event, ctx) => { if (!ctx.hasUI) { return; } ctx.ui.setEditorComponent((tui, theme, keybindings) => new FullTextPasteEditor( tui, theme, keybindings, (message, level) => ctx.ui.notify(message, level), )); }); } function sanitizePastedText( rawText: string, lines: string[], cursor: { line: number; col: number }, ): string { let text = normalizeText(rawText) .split("") .filter((char) => char === "\n" || char.charCodeAt(0) >= 32) .join(""); if (/^[/~.]/.test(text)) { const currentLine = lines[cursor.line] ?? ""; const charBeforeCursor = cursor.col > 0 ? currentLine[cursor.col - 1] ?? "" : ""; if (charBeforeCursor && /\w/.test(charBeforeCursor)) { text = ` ${text}`; } } return text; } function normalizeText(text: string): string { return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " "); }