afc4f3c1fe
fix(coding-agent): avoid placeholder crash before theme init Resolved CHANGELOG conflict: 15.13.0 was re-opened into [Unreleased] on main, so the fix entry goes under the existing [Unreleased] Fixed section rather than resurrecting the released 15.13.0 heading.
555 lines
21 KiB
TypeScript
555 lines
21 KiB
TypeScript
import { addKeyAliases, canonicalKeyId, Editor, type KeyId, parseKey, parseKittySequence } from "@oh-my-pi/pi-tui";
|
|
import type { AppKeybinding } from "../../config/keybindings";
|
|
import { isSettingsInitialized, settings } from "../../config/settings";
|
|
import { imageReferenceHyperlink, PLACEHOLDER_REGEX, renderPlaceholders } from "../image-references";
|
|
import { hasMagicKeyword, highlightMagicKeywords } from "../magic-keywords";
|
|
import { fgOrPlain } 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"],
|
|
};
|
|
|
|
function buildMatchKeys(keys: readonly KeyId[]): Set<string> {
|
|
const matchKeys = new Set<string>();
|
|
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;
|
|
|
|
/** Plain spaces from one auto-repeat run that trigger the space-hold push-to-talk STT gesture.
|
|
* Holding the space bar makes the terminal emit a burst of spaces; once more than this many land
|
|
* in the editor we treat it as "space held", track them back out, and start recording. */
|
|
export const SPACE_HOLD_THRESHOLD = 5;
|
|
/** Idle gap (ms) after the last repeated space that counts as the space bar being released, ending
|
|
* the push-to-talk recording. Must comfortably exceed the OS key-repeat interval. */
|
|
export const SPACE_HOLD_RELEASE_MS = 250;
|
|
|
|
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;
|
|
|
|
/** Magic-keyword shimmer cadence — drives one editor repaint every 70 ms while
|
|
* a keyword is on screen and the prompt is focused. ~14 frames/s is smooth
|
|
* without flooding the renderer. */
|
|
static readonly SHIMMER_FRAME_MS = 70;
|
|
/** Time for the gradient to sweep one full cycle across each keyword. */
|
|
static readonly SHIMMER_PERIOD_MS = 1800;
|
|
|
|
/** Per-render scratch flag: did any layout line in this render contain a magic
|
|
* keyword that should shimmer? Reset by {@link #scheduleShimmerIfNeeded} each
|
|
* time a frame is queued. */
|
|
#shimmerTimer: ReturnType<typeof setTimeout> | undefined;
|
|
/** Repaint hook the host wires once at construction. Called from the shimmer
|
|
* timer to request the next animation frame. Undefined when nobody is
|
|
* listening (tests, headless callers); the timer chain still self-cleans. */
|
|
#requestShimmerRepaint: (() => void) | undefined;
|
|
|
|
/** 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.
|
|
* When the editor is focused, the buffer contains a magic keyword, and `magicKeywords.enabled`
|
|
* is on, the gradient shifts every frame to produce a Claude-Code-style shimmer; each render
|
|
* schedules the next frame, so losing focus, deleting the keyword, or flipping the setting
|
|
* stops the animation on its own. The static glow itself runs even when shimmering is gated
|
|
* off, matching existing behavior for the editor and sent bubbles. */
|
|
decorateText = (text: string): string => {
|
|
const animated = this.focused && this.#shimmerEnabled() && hasMagicKeyword(this.getText());
|
|
const phase = animated ? (Date.now() % CustomEditor.SHIMMER_PERIOD_MS) / CustomEditor.SHIMMER_PERIOD_MS : 0;
|
|
if (animated) this.#scheduleShimmerFrame();
|
|
return renderPlaceholders(text, {
|
|
renderText: value => highlightMagicKeywords(value, undefined, phase),
|
|
renderReference: (value, kind, index) =>
|
|
kind === "image"
|
|
? imageReferenceHyperlink(value, index, this.imageLinks, label =>
|
|
fgOrPlain("accent", label, `\x1b[1m\x1b[4m${label}\x1b[24m\x1b[22m`),
|
|
)
|
|
: fgOrPlain("accent", value, `\x1b[1m${value}\x1b[22m`),
|
|
});
|
|
};
|
|
|
|
/** Optional test/host override for the magic-keyword shimmer gate. When
|
|
* defined, takes precedence over the global `magicKeywords.enabled` setting,
|
|
* letting tests assert the gating behaviour without mutating the
|
|
* process-wide Settings singleton (which races with parallel test files —
|
|
* see issue #2582). Production wires this through the host's Settings
|
|
* reader and updates it on the relevant setting change. */
|
|
magicKeywordsEnabledOverride: boolean | undefined;
|
|
|
|
/** Whether the shimmer should advance this frame. Defaults to "on" before
|
|
* settings have initialised (tests, early boot) so the animation does not
|
|
* silently disappear during a race; settings disabling the feature wins
|
|
* once they are loaded. An explicit `magicKeywordsEnabledOverride` overrides
|
|
* both paths. */
|
|
#shimmerEnabled(): boolean {
|
|
if (this.magicKeywordsEnabledOverride !== undefined) return this.magicKeywordsEnabledOverride;
|
|
return isSettingsInitialized() ? settings.get("magicKeywords.enabled") : true;
|
|
}
|
|
|
|
/** Bind the host's render request callback. Idempotent — the host wires this
|
|
* once after construction (and again after `setEditorComponent` swaps the
|
|
* editor). Passing `undefined` clears any pending frame. */
|
|
setShimmerRepaintHandler(handler: (() => void) | undefined): void {
|
|
this.#requestShimmerRepaint = handler;
|
|
if (!handler && this.#shimmerTimer) {
|
|
clearTimeout(this.#shimmerTimer);
|
|
this.#shimmerTimer = undefined;
|
|
}
|
|
}
|
|
|
|
/** Schedule one shimmer frame if none is already pending. The next render
|
|
* decides whether to schedule another, so the chain stops by itself when
|
|
* `focused` flips off or the keyword leaves the buffer. */
|
|
#scheduleShimmerFrame(): void {
|
|
if (this.#shimmerTimer || !this.#requestShimmerRepaint) return;
|
|
this.#shimmerTimer = setTimeout(() => {
|
|
this.#shimmerTimer = undefined;
|
|
this.#requestShimmerRepaint?.();
|
|
}, CustomEditor.SHIMMER_FRAME_MS);
|
|
this.#shimmerTimer.unref?.();
|
|
}
|
|
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 one or more image-file paths. */
|
|
onPasteImagePath?: (path: string) => void | Promise<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;
|
|
/** Called when left-arrow is pressed while the editor is empty (cursor necessarily at start). */
|
|
onLeftAtStart?: () => void;
|
|
|
|
/** Fired when a sustained space-bar hold is recognized — the push-to-talk STT start. The
|
|
* optimistically-typed spaces have already been deleted by the time this runs. */
|
|
onSpaceHoldStart?: () => void;
|
|
/** Fired when the held space bar is released (detected as an idle gap with no further repeated
|
|
* spaces) — the push-to-talk STT stop. */
|
|
onSpaceHoldEnd?: () => void;
|
|
/** Gate for the space-hold gesture. Returns false to keep the space bar inserting spaces
|
|
* normally; wired to `stt.enabled` so disabling STT restores plain space behavior. */
|
|
sttHoldEnabled?: () => boolean;
|
|
|
|
/** Custom key handlers from extensions and non-built-in app actions. */
|
|
#customKeyHandlers = new Map<KeyId, () => void>();
|
|
#customMatchKeys = new Map<string, () => void>();
|
|
/** Consecutive plain spaces inserted in the current run; any other key resets it. */
|
|
#spaceRunInserted = 0;
|
|
/** True while a recognized space-hold push-to-talk recording is in progress. */
|
|
#spaceHoldActive = false;
|
|
/** Idle timer that fires `onSpaceHoldEnd` once repeated spaces stop arriving. */
|
|
#spaceHoldTimer: NodeJS.Timeout | undefined;
|
|
#actionKeys = new Map<ConfigurableEditorAction, KeyId[]>(
|
|
Object.entries(DEFAULT_ACTION_KEYS).map(([action, keys]) => [action as ConfigurableEditorAction, [...keys]]),
|
|
);
|
|
#actionMatchKeys = new Map<ConfigurableEditorAction, Set<string>>(
|
|
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();
|
|
}
|
|
|
|
#spaceHoldGestureEnabled(): boolean {
|
|
return this.onSpaceHoldStart !== undefined && (this.sttHoldEnabled?.() ?? false) && !this.isShowingAutocomplete();
|
|
}
|
|
|
|
/** Drive the space-hold push-to-talk state machine. Returns true when the gesture consumed the
|
|
* input so it must not reach normal editing. Holding the space bar makes the terminal emit a
|
|
* burst of auto-repeat spaces; once more than {@link SPACE_HOLD_THRESHOLD} of them land we treat
|
|
* it as a hold, delete the spam, and start recording until the repeats stop. */
|
|
#handleSpaceHold(data: string, canonical: string | undefined): boolean {
|
|
const isSpace = canonical === "space";
|
|
if (this.#spaceHoldActive) {
|
|
if (isSpace) {
|
|
// Auto-repeat while held: swallow it and keep the release timer alive.
|
|
this.#armSpaceHoldReleaseTimer();
|
|
return true;
|
|
}
|
|
// Any non-space means the bar was released — stop recording, then let the key through.
|
|
this.#endSpaceHold();
|
|
return false;
|
|
}
|
|
if (!isSpace) {
|
|
this.#spaceRunInserted = 0;
|
|
return false;
|
|
}
|
|
if (!this.#spaceHoldGestureEnabled()) return false;
|
|
// A short tap should still type a normal space, so insert optimistically and count the run.
|
|
super.handleInput(data);
|
|
this.#spaceRunInserted++;
|
|
if (this.#spaceRunInserted > SPACE_HOLD_THRESHOLD) {
|
|
this.deleteBeforeCursor(this.#spaceRunInserted);
|
|
this.#spaceRunInserted = 0;
|
|
this.#beginSpaceHold();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
#beginSpaceHold(): void {
|
|
this.#spaceHoldActive = true;
|
|
this.#armSpaceHoldReleaseTimer();
|
|
this.onSpaceHoldStart?.();
|
|
}
|
|
|
|
#armSpaceHoldReleaseTimer(): void {
|
|
if (this.#spaceHoldTimer) clearTimeout(this.#spaceHoldTimer);
|
|
this.#spaceHoldTimer = setTimeout(() => {
|
|
this.#spaceHoldTimer = undefined;
|
|
this.#endSpaceHold();
|
|
}, SPACE_HOLD_RELEASE_MS);
|
|
this.#spaceHoldTimer.unref?.();
|
|
}
|
|
|
|
#endSpaceHold(): void {
|
|
if (!this.#spaceHoldActive) return;
|
|
this.#spaceHoldActive = false;
|
|
this.#spaceRunInserted = 0;
|
|
if (this.#spaceHoldTimer) {
|
|
clearTimeout(this.#spaceHoldTimer);
|
|
this.#spaceHoldTimer = undefined;
|
|
}
|
|
this.onSpaceHoldEnd?.();
|
|
}
|
|
|
|
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;
|
|
|
|
// Left-arrow on an empty editor: surface for the agent-hub double-tap
|
|
// gesture. Plain "left" only — modified arrows and any in-text cursor
|
|
// movement fall through to normal handling.
|
|
if (canonical === "left" && this.onLeftAtStart && this.getText().trim() === "") {
|
|
this.onLeftAtStart();
|
|
return;
|
|
}
|
|
|
|
// Space-hold push-to-talk: a sustained space bar starts/stops STT instead of typing spaces.
|
|
if (this.#handleSpaceHold(data, canonical)) return;
|
|
|
|
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);
|
|
}
|
|
}
|