6adf98fd10
- Added OSC5522 parsing and a payload controller that prefers image MIME and reassembles chunks. - Added bracketed image-path detection and `onPasteImagePath` callback wiring in the editor. - Added local image-path paste insertion with fallback to text paste on image-size errors. - Added `Editor.pasteText()` and OSC5522 disable sequencing on terminal cleanup.
291 lines
9.4 KiB
TypeScript
291 lines
9.4 KiB
TypeScript
import { Editor, type KeyId, matchesKey, parseKittySequence } from "@oh-my-pi/pi-tui";
|
|
import type { AppKeybinding } from "../../config/keybindings";
|
|
import { imageReferenceHyperlink, renderImageReferences } 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<ConfigurableEditorAction, KeyId[]> = {
|
|
"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"],
|
|
};
|
|
|
|
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i;
|
|
|
|
export function extractBracketedImagePastePath(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 || /[\r\n]/.test(pasted)) return undefined;
|
|
if (!BRACKETED_IMAGE_PATH_REGEX.test(pasted)) return undefined;
|
|
return pasted;
|
|
}
|
|
|
|
/**
|
|
* Custom editor that handles configurable app-level shortcuts for coding-agent.
|
|
*/
|
|
export class CustomEditor extends Editor {
|
|
imageLinks?: readonly (string | undefined)[];
|
|
|
|
/** Gradient-highlight the "ultrathink" / "orchestrate" / "workflow" 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 =>
|
|
renderImageReferences(text, {
|
|
renderText: value => highlightMagicKeywords(value),
|
|
renderReference: (value, index) =>
|
|
imageReferenceHyperlink(value, index, this.imageLinks, label =>
|
|
theme.fg("accent", `\x1b[1m\x1b[4m${label}\x1b[24m\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<boolean>;
|
|
/** Called when a bracketed paste contains exactly one image-file path. */
|
|
onPasteImagePath?: (path: string) => void;
|
|
/** 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<KeyId, () => void>();
|
|
#actionKeys = new Map<ConfigurableEditorAction, KeyId[]>(
|
|
Object.entries(DEFAULT_ACTION_KEYS).map(([action, keys]) => [action as ConfigurableEditorAction, [...keys]]),
|
|
);
|
|
|
|
setActionKeys(action: ConfigurableEditorAction, keys: KeyId[]): void {
|
|
this.#actionKeys.set(action, [...keys]);
|
|
}
|
|
|
|
#matchesAction(data: string, action: ConfigurableEditorAction): boolean {
|
|
const keys = this.#actionKeys.get(action);
|
|
if (!keys) return false;
|
|
for (const key of keys) {
|
|
if (matchesKey(data, key)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Register a custom key handler. Extensions use this for shortcuts.
|
|
*/
|
|
setCustomKeyHandler(key: KeyId, handler: () => void): void {
|
|
this.#customKeyHandlers.set(key, handler);
|
|
}
|
|
|
|
/**
|
|
* Remove a custom key handler.
|
|
*/
|
|
removeCustomKeyHandler(key: KeyId): void {
|
|
this.#customKeyHandlers.delete(key);
|
|
}
|
|
|
|
/**
|
|
* Clear all custom key handlers.
|
|
*/
|
|
clearCustomKeyHandlers(): void {
|
|
this.#customKeyHandlers.clear();
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
const parsed = parseKittySequence(data);
|
|
if (parsed && (parsed.modifier & 64) !== 0 && this.onCapsLock) {
|
|
// Caps Lock is modifier bit 64
|
|
this.onCapsLock();
|
|
return;
|
|
}
|
|
|
|
const pastedImagePath = extractBracketedImagePastePath(data);
|
|
if (pastedImagePath && this.onPasteImagePath) {
|
|
this.onPasteImagePath(pastedImagePath);
|
|
return;
|
|
}
|
|
|
|
// Intercept configured image paste (async - fires and handles result)
|
|
if (this.#matchesAction(data, "app.clipboard.pasteImage") && this.onPasteImage) {
|
|
void this.onPasteImage();
|
|
return;
|
|
}
|
|
|
|
// Intercept configured raw text paste (fires and handles result)
|
|
if (this.#matchesAction(data, "app.clipboard.pasteTextRaw") && this.onPasteTextRaw) {
|
|
this.onPasteTextRaw();
|
|
return;
|
|
}
|
|
|
|
// Intercept configured external editor shortcut
|
|
if (this.#matchesAction(data, "app.editor.external") && this.onExternalEditor) {
|
|
this.onExternalEditor();
|
|
return;
|
|
}
|
|
|
|
// Intercept configured temporary model selector shortcut
|
|
if (this.#matchesAction(data, "app.model.selectTemporary") && this.onSelectModelTemporary) {
|
|
this.onSelectModelTemporary();
|
|
return;
|
|
}
|
|
|
|
// Intercept configured display reset shortcut
|
|
if (this.#matchesAction(data, "app.display.reset") && this.onDisplayReset) {
|
|
this.onDisplayReset();
|
|
return;
|
|
}
|
|
|
|
// Intercept configured suspend shortcut
|
|
if (this.#matchesAction(data, "app.suspend") && this.onSuspend) {
|
|
this.onSuspend();
|
|
return;
|
|
}
|
|
|
|
// Intercept configured thinking block visibility toggle
|
|
if (this.#matchesAction(data, "app.thinking.toggle") && this.onToggleThinking) {
|
|
this.onToggleThinking();
|
|
return;
|
|
}
|
|
|
|
// Intercept configured model selector shortcut
|
|
if (this.#matchesAction(data, "app.model.select") && this.onSelectModel) {
|
|
this.onSelectModel();
|
|
return;
|
|
}
|
|
|
|
// Intercept configured history search shortcut
|
|
if (this.#matchesAction(data, "app.history.search") && this.onHistorySearch) {
|
|
this.onHistorySearch();
|
|
return;
|
|
}
|
|
|
|
// Intercept configured tool output expansion shortcut
|
|
if (this.#matchesAction(data, "app.tools.expand") && this.onExpandTools) {
|
|
this.onExpandTools();
|
|
return;
|
|
}
|
|
|
|
// Intercept configured backward model cycling (check before forward cycling)
|
|
if (this.#matchesAction(data, "app.model.cycleBackward") && this.onCycleModelBackward) {
|
|
this.onCycleModelBackward();
|
|
return;
|
|
}
|
|
|
|
// Intercept configured forward model cycling
|
|
if (this.#matchesAction(data, "app.model.cycleForward") && this.onCycleModelForward) {
|
|
this.onCycleModelForward();
|
|
return;
|
|
}
|
|
|
|
// Intercept configured thinking level cycling
|
|
if (this.#matchesAction(data, "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(data, "app.interrupt") && this.onEscape && !this.isShowingAutocomplete()) {
|
|
this.onEscape();
|
|
return;
|
|
}
|
|
|
|
// Intercept configured clear shortcut
|
|
if (this.#matchesAction(data, "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(data, "app.exit")) {
|
|
this.onExit?.();
|
|
return;
|
|
}
|
|
|
|
// Intercept configured dequeue shortcut (restore queued message to editor)
|
|
if (this.#matchesAction(data, "app.message.dequeue") && this.onDequeue) {
|
|
this.onDequeue();
|
|
return;
|
|
}
|
|
|
|
// Intercept configured copy-prompt shortcut
|
|
if (this.#matchesAction(data, "app.clipboard.copyPrompt") && this.onCopyPrompt) {
|
|
this.onCopyPrompt();
|
|
return;
|
|
}
|
|
|
|
// Check custom key handlers (extensions)
|
|
for (const [keyId, handler] of this.#customKeyHandlers) {
|
|
if (matchesKey(data, keyId)) {
|
|
handler();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Pass to parent for normal handling
|
|
super.handleInput(data);
|
|
}
|
|
}
|