/**
* Double Escape clears a non-empty editor draft.
*
* Built-in double-Escape only acts on an *empty* editor (tree / fork / none via
* `doubleEscapeAction`). When the editor has normal draft text, Escape is a
* no-op while idle. This extension makes Escape → Escape (within 800ms) clear
* that draft instead.
*
* A non-empty plain draft owns Esc even while the agent is streaming
* (decided 2026-07-20): the draft must never be lost to an accidental
* abort-and-autosubmit, so busy Esc on such a draft is swallowed here
* rather than falling through to the app's abort handler.
*
* Must not steal these built-in Escape paths:
* - bash-running abort
* - bash-mode (`!…`) single-Esc clear/exit
* - autocomplete cancel
* - empty / whitespace-only double-Esc (`doubleEscapeAction`)
* - streaming abort, when the draft is empty/bash-mode
*
* Vendored from @thisux/pi-double-esc-clear v1.0.3 (MIT, author Sanju
* ). Exposed as a register function gated by a live
* `isEnabled` getter so the /cc-my-pi settings toggle takes effect without a
* Pi reload. Adds an `EDITOR_FEATURES` marker (absent in the standalone
* package) so a second bundled install does not double-wrap.
*
* First Esc also shows a transient, right-aligned "Esc again to clear" hint
* above the editor, auto-hiding after the second-Esc window (or sooner on
* clear / other input).
*/
import {
CustomEditor,
type ExtensionAPI,
type ExtensionContext,
type KeybindingsManager,
} from "@earendil-works/pi-coding-agent";
import { matchesKey } from "@earendil-works/pi-tui";
/** Second-Esc accept window — also how long the hint stays visible. */
const DOUBLE_ESC_MS = 2_000;
const EDITOR_FEATURES = Symbol.for("@tmustier/pi-editor-features");
const FEATURE = "double-esc-clear";
const HINT_WIDGET_KEY = "cc-my-pi-double-esc-hint";
const HINT_TEXT = "Esc again to clear";
type EditorFactory = NonNullable>;
type ComposedEditorFactory = EditorFactory & { [EDITOR_FEATURES]?: ReadonlySet };
function editorFeatures(factory: EditorFactory | undefined): ReadonlySet {
return (factory as ComposedEditorFactory | undefined)?.[EDITOR_FEATURES] ?? new Set();
}
/** Draft Pi would leave alone on Escape (not empty, not `!` bash mode). */
function isClearableDraft(text: string): boolean {
const trimmed = text.trim();
if (trimmed.length === 0) return false;
// Bash mode is exited with a single Escape by the app.
if (text.trimStart().startsWith("!")) return false;
return true;
}
export function registerBundledDoubleEscClear(
pi: ExtensionAPI,
isEnabled: () => boolean,
): void {
let hintTimer: ReturnType | undefined;
let hintCtx: ExtensionContext | undefined;
const hideHint = (): void => {
if (hintTimer) clearTimeout(hintTimer);
hintTimer = undefined;
hintCtx?.ui.setWidget(HINT_WIDGET_KEY, undefined);
hintCtx = undefined;
};
const showHint = (ctx: ExtensionContext): void => {
hideHint();
hintCtx = ctx;
ctx.ui.setWidget(
HINT_WIDGET_KEY,
() => ({
render(width: number): string[] {
const pad = Math.max(0, width - HINT_TEXT.length);
return [`${" ".repeat(pad)}\x1b[2m${HINT_TEXT}\x1b[0m`];
},
invalidate(): void {
// Static content; nothing to invalidate.
},
}),
{ placement: "aboveEditor" },
);
hintTimer = setTimeout(hideHint, DOUBLE_ESC_MS);
};
let installTimer: ReturnType | undefined;
const installEditor = (ctx: ExtensionContext): void => {
if (ctx.mode !== "tui") return;
const previous = ctx.ui.getEditorComponent();
const features = editorFeatures(previous);
// Dedup: we are already in this factory chain.
if (features.has(FEATURE)) return;
const factory = ((tui, theme, keybindings: KeybindingsManager) => {
const editor = (previous?.(tui, theme, keybindings) ??
new CustomEditor(tui, theme, keybindings)) as CustomEditor;
// Capability check instead of instanceof: other extensions (e.g.
// pi-raw-paste) build their editor from a DIFFERENT copy of
// pi-coding-agent (cc-my-pi ships its own node_modules), so
// `instanceof CustomEditor` is false cross-realm even though the
// editor is fully compatible — and the feature silently died.
if (
typeof editor.getText !== "function" ||
typeof editor.setText !== "function" ||
typeof editor.isShowingAutocomplete !== "function" ||
typeof editor.handleInput !== "function"
) {
return editor;
}
// Framework rebinds onEscape after the factory returns, so intercept
// handleInput rather than assigning onEscape here.
const originalHandleInput = editor.handleInput.bind(editor);
let lastEscapeTime = 0;
editor.handleInput = (data: string) => {
const isEscape =
typeof keybindings.matches === "function"
? keybindings.matches(data, "app.interrupt")
: matchesKey(data, "escape");
if (
isEscape &&
isEnabled() &&
!editor.isShowingAutocomplete() &&
isClearableDraft(editor.getText())
) {
const now = Date.now();
if (now - lastEscapeTime < DOUBLE_ESC_MS) {
// Second Esc on a plain draft → clear. Never falls through.
editor.setText("");
lastEscapeTime = 0;
hideHint();
return;
}
lastEscapeTime = now;
showHint(ctx);
if (ctx.isIdle()) {
// Idle: fall through so bash-running / other app Escape
// handlers still run when applicable.
originalHandleInput(data);
}
// Busy: swallow — falling through would abort the run; the
// draft must never cost the user their running turn.
return;
}
if (!isEscape) {
lastEscapeTime = 0;
if (hintTimer) hideHint();
}
originalHandleInput(data);
};
return editor;
}) as ComposedEditorFactory;
factory[EDITOR_FEATURES] = new Set([...features, FEATURE]);
ctx.ui.setEditorComponent(factory);
};
// Some extensions (e.g. pi-raw-paste) REPLACE the editor factory at
// session_start without chaining the previous one, and handler order is
// not guaranteed — a single immediate install can be silently discarded.
// Install immediately AND on a later tick (and again on agent_start),
// like esc-steer/queue-steer do; the EDITOR_FEATURES marker makes
// re-installs idempotent.
const scheduleInstall = (ctx: ExtensionContext): void => {
if (installTimer) clearTimeout(installTimer);
installTimer = setTimeout(() => {
installTimer = undefined;
installEditor(ctx);
}, 0);
};
pi.on("session_start", (_event, ctx) => {
installEditor(ctx);
scheduleInstall(ctx);
});
pi.on("agent_start", (_event, ctx) => {
installEditor(ctx);
scheduleInstall(ctx);
});
pi.on("session_shutdown", () => {
if (installTimer) clearTimeout(installTimer);
installTimer = undefined;
hideHint();
});
}