import { addKeyAliases, canonicalKeyId, Editor, type KeyId, parseKey, parseKittySequence } from "@oh-my-pi/pi-tui"; import type { AppKeybinding } from "../../config/keybindings"; import { imageReferenceHyperlink, PLACEHOLDER_REGEX, renderPlaceholders } from "../image-references"; import { highlightMagicKeywords } from "../magic-keywords"; import { theme } from "../theme/theme"; type ConfigurableEditorAction = Extract< AppKeybinding, | "app.interrupt" | "app.clear" | "app.exit" | "app.suspend" | "app.display.reset" | "app.thinking.cycle" | "app.model.cycleForward" | "app.model.cycleBackward" | "app.model.select" | "app.model.selectTemporary" | "app.tools.expand" | "app.thinking.toggle" | "app.editor.external" | "app.history.search" | "app.message.dequeue" | "app.clipboard.pasteImage" | "app.clipboard.pasteTextRaw" | "app.clipboard.copyPrompt" >; const DEFAULT_ACTION_KEYS: Record = { "app.interrupt": ["escape"], "app.clear": ["ctrl+c"], "app.exit": ["ctrl+d"], "app.suspend": ["ctrl+z"], "app.display.reset": ["ctrl+l"], "app.thinking.cycle": ["shift+tab"], "app.model.cycleForward": ["ctrl+p"], "app.model.cycleBackward": ["shift+ctrl+p"], "app.model.select": ["alt+m"], "app.model.selectTemporary": ["alt+p"], "app.tools.expand": ["ctrl+o"], "app.thinking.toggle": ["ctrl+t"], "app.editor.external": ["ctrl+g"], "app.history.search": ["ctrl+r"], "app.message.dequeue": ["alt+up"], "app.clipboard.pasteImage": ["ctrl+v"], "app.clipboard.pasteTextRaw": ["ctrl+shift+v", "alt+shift+v"], "app.clipboard.copyPrompt": ["alt+shift+c"], }; function buildMatchKeys(keys: readonly KeyId[]): Set { const matchKeys = new Set(); for (const key of keys) { addKeyAliases(matchKeys, key); } return matchKeys; } const BRACKETED_PASTE_START = "\x1b[200~"; const BRACKETED_PASTE_END = "\x1b[201~"; const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i; const BRACKETED_IMAGE_PATH_BOUNDARY_REGEX = /\.(?:png|jpe?g|gif|webp)(?=$|["']?\s)/gi; const SHELL_ESCAPED_PATH_CHAR_REGEX = /\\([\\\s'"()[\]{}&;<>|?*!$`])/g; function isPastedPathSeparator(char: string | undefined): boolean { return char === undefined || char === " " || char === "\t" || char === "\r" || char === "\n"; } function imagePathBoundaryEnd(payload: string, segmentStart: number, extensionEnd: number): number | undefined { const quote = payload[segmentStart]; const afterExtension = payload[extensionEnd]; if (quote === '"' || quote === "'") { return afterExtension === quote && isPastedPathSeparator(payload[extensionEnd + 1]) ? extensionEnd + 1 : undefined; } if (isPastedPathSeparator(afterExtension)) return extensionEnd; return undefined; } function normalizePastedImagePath(path: string): string { const trimmed = path.trim(); const first = trimmed[0]; const last = trimmed[trimmed.length - 1]; const unquoted = trimmed.length > 1 && (first === '"' || first === "'") && last === first ? trimmed.slice(1, -1) : trimmed; return unquoted.replace(SHELL_ESCAPED_PATH_CHAR_REGEX, "$1"); } export function extractBracketedImagePastePaths(data: string): string[] | undefined { if (!data.startsWith(BRACKETED_PASTE_START)) return undefined; const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length); if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined; const pasted = data.slice(BRACKETED_PASTE_START.length, endIndex).trim(); if (!pasted) return undefined; const paths: string[] = []; let segmentStart = 0; BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.lastIndex = 0; for ( let match = BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.exec(pasted); match; match = BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.exec(pasted) ) { const extensionEnd = match.index + match[0].length; const boundaryEnd = imagePathBoundaryEnd(pasted, segmentStart, extensionEnd); if (boundaryEnd === undefined) continue; const path = normalizePastedImagePath(pasted.slice(segmentStart, boundaryEnd)); if (!path || !BRACKETED_IMAGE_PATH_REGEX.test(path)) return undefined; paths.push(path); segmentStart = boundaryEnd; while (segmentStart < pasted.length && isPastedPathSeparator(pasted[segmentStart])) { segmentStart++; } BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.lastIndex = segmentStart; } if (paths.length === 0 || segmentStart !== pasted.length) return undefined; return paths; } export function extractBracketedImagePastePath(data: string): string | undefined { const paths = extractBracketedImagePastePaths(data); return paths?.length === 1 ? paths[0] : undefined; } /** * Custom editor that handles configurable app-level shortcuts for coding-agent. */ export class CustomEditor extends Editor { imageLinks?: readonly (string | undefined)[]; /** Treat image/paste markers as indivisible: a stray backspace deletes the whole token * instead of corrupting `[Paste #1, +30 lines]` into plain text. */ override atomicTokenPattern = PLACEHOLDER_REGEX; /** Gradient-highlight the "ultrathink" / "orchestrate" / "workflowz" keywords as the user types * them, skipping any occurrence inside code spans, fenced blocks, or XML sections. Also make * pasted image placeholders visually distinct and hyperlink them once their blob file exists. */ decorateText = (text: string): string => renderPlaceholders(text, { renderText: value => highlightMagicKeywords(value), renderReference: (value, kind, index) => kind === "image" ? imageReferenceHyperlink(value, index, this.imageLinks, label => theme.fg("accent", `\x1b[1m\x1b[4m${label}\x1b[24m\x1b[22m`), ) : theme.fg("accent", `\x1b[1m${value}\x1b[22m`), }); onEscape?: () => void; onClear?: () => void; onExit?: () => void; onDisplayReset?: () => void; onCycleThinkingLevel?: () => void; onCycleModelForward?: () => void; onCycleModelBackward?: () => void; onSelectModel?: () => void; onExpandTools?: () => void; onToggleThinking?: () => void; onExternalEditor?: () => void; onHistorySearch?: () => void; onSuspend?: () => void; onSelectModelTemporary?: () => void; /** Called when the configured copy-prompt shortcut is pressed. */ onCopyPrompt?: () => void; /** Called when the configured image-paste shortcut is pressed. */ onPasteImage?: () => Promise; /** Called when a bracketed paste contains one or more image-file paths. */ onPasteImagePath?: (path: string) => void | Promise; /** Called when the configured raw text-paste shortcut is pressed. */ onPasteTextRaw?: () => void; /** Called when the configured dequeue shortcut is pressed. */ onDequeue?: () => void; /** Called when Caps Lock is pressed. */ onCapsLock?: () => void; /** Custom key handlers from extensions and non-built-in app actions. */ #customKeyHandlers = new Map void>(); #customMatchKeys = new Map void>(); #actionKeys = new Map( Object.entries(DEFAULT_ACTION_KEYS).map(([action, keys]) => [action as ConfigurableEditorAction, [...keys]]), ); #actionMatchKeys = new Map>( Object.entries(DEFAULT_ACTION_KEYS).map(([action, keys]) => [ action as ConfigurableEditorAction, buildMatchKeys(keys), ]), ); setActionKeys(action: ConfigurableEditorAction, keys: KeyId[]): void { this.#actionKeys.set(action, [...keys]); this.#rebuildActionMatchKeys(action); } #rebuildActionMatchKeys(action: ConfigurableEditorAction): void { this.#actionMatchKeys.set(action, buildMatchKeys(this.#actionKeys.get(action) ?? [])); } #rebuildCustomMatchKeys(): void { this.#customMatchKeys.clear(); for (const [keyId, handler] of this.#customKeyHandlers) { for (const alias of buildMatchKeys([keyId])) { // Preserve current iteration behavior: the first registered handler for colliding aliases wins. if (!this.#customMatchKeys.has(alias)) this.#customMatchKeys.set(alias, handler); } } } #matchesAction(canonical: string | undefined, action: ConfigurableEditorAction): boolean { return canonical !== undefined && (this.#actionMatchKeys.get(action)?.has(canonical) ?? false); } /** * Register a custom key handler. Extensions use this for shortcuts. */ setCustomKeyHandler(key: KeyId, handler: () => void): void { this.#customKeyHandlers.set(key, handler); this.#rebuildCustomMatchKeys(); } /** * Remove a custom key handler. */ removeCustomKeyHandler(key: KeyId): void { this.#customKeyHandlers.delete(key); this.#rebuildCustomMatchKeys(); } /** * Clear all custom key handlers. */ clearCustomKeyHandlers(): void { this.#customKeyHandlers.clear(); this.#rebuildCustomMatchKeys(); } handleInput(data: string): void { const kittyParsed = parseKittySequence(data); if (kittyParsed && (kittyParsed.modifier & 64) !== 0 && this.onCapsLock) { // Caps Lock is modifier bit 64 this.onCapsLock(); return; } const pastedImagePaths = extractBracketedImagePastePaths(data); if (pastedImagePaths && this.onPasteImagePath) { void (async () => { for (const path of pastedImagePaths) { await this.onPasteImagePath?.(path); } })(); return; } const parsedKey = parseKey(data); const canonical = parsedKey !== undefined ? canonicalKeyId(parsedKey) : undefined; if (canonical !== undefined) { // Intercept configured image paste (async - fires and handles result) if (this.#matchesAction(canonical, "app.clipboard.pasteImage") && this.onPasteImage) { void this.onPasteImage(); return; } // Intercept configured raw text paste (fires and handles result) if (this.#matchesAction(canonical, "app.clipboard.pasteTextRaw") && this.onPasteTextRaw) { this.onPasteTextRaw(); return; } // Intercept configured external editor shortcut if (this.#matchesAction(canonical, "app.editor.external") && this.onExternalEditor) { this.onExternalEditor(); return; } // Intercept configured temporary model selector shortcut if (this.#matchesAction(canonical, "app.model.selectTemporary") && this.onSelectModelTemporary) { this.onSelectModelTemporary(); return; } // Intercept configured display reset shortcut if (this.#matchesAction(canonical, "app.display.reset") && this.onDisplayReset) { this.onDisplayReset(); return; } // Intercept configured suspend shortcut if (this.#matchesAction(canonical, "app.suspend") && this.onSuspend) { this.onSuspend(); return; } // Intercept configured thinking block visibility toggle if (this.#matchesAction(canonical, "app.thinking.toggle") && this.onToggleThinking) { this.onToggleThinking(); return; } // Intercept configured model selector shortcut if (this.#matchesAction(canonical, "app.model.select") && this.onSelectModel) { this.onSelectModel(); return; } // Intercept configured history search shortcut if (this.#matchesAction(canonical, "app.history.search") && this.onHistorySearch) { this.onHistorySearch(); return; } // Intercept configured tool output expansion shortcut if (this.#matchesAction(canonical, "app.tools.expand") && this.onExpandTools) { this.onExpandTools(); return; } // Intercept configured backward model cycling (check before forward cycling) if (this.#matchesAction(canonical, "app.model.cycleBackward") && this.onCycleModelBackward) { this.onCycleModelBackward(); return; } // Intercept configured forward model cycling if (this.#matchesAction(canonical, "app.model.cycleForward") && this.onCycleModelForward) { this.onCycleModelForward(); return; } // Intercept configured thinking level cycling if (this.#matchesAction(canonical, "app.thinking.cycle") && this.onCycleThinkingLevel) { this.onCycleThinkingLevel(); return; } // Intercept configured interrupt shortcut. // When the autocomplete popup is visible, ESC's first job is to dismiss // the popup — let super.handleInput() route it to #cancelAutocomplete(). // The user can press ESC again afterward to fire the global interrupt // handler. This matches the standard TUI/IDE pattern and prevents a // single ESC from both closing an @ completion and aborting an active // agent run (#1655). if (this.#matchesAction(canonical, "app.interrupt") && this.onEscape && !this.isShowingAutocomplete()) { this.onEscape(); return; } // Intercept configured clear shortcut if (this.#matchesAction(canonical, "app.clear") && this.onClear) { this.onClear(); return; } // Intercept configured exit shortcut. Always consume the shortcut so it // never reaches the parent handler; firing onExit is the controller's // chance to snapshot the current text as a draft before shutting down. if (this.#matchesAction(canonical, "app.exit")) { this.onExit?.(); return; } // Intercept configured dequeue shortcut (restore queued message to editor) if (this.#matchesAction(canonical, "app.message.dequeue") && this.onDequeue) { this.onDequeue(); return; } // Intercept configured copy-prompt shortcut if (this.#matchesAction(canonical, "app.clipboard.copyPrompt") && this.onCopyPrompt) { this.onCopyPrompt(); return; } // Check custom key handlers (extensions) const handler = this.#customMatchKeys.get(canonical); if (handler) { handler(); return; } } // Pass to parent for normal handling super.handleInput(data); } }