Files
pi-skill/extensions/escape-cancel.ts
2026-05-25 16:41:08 +07:00

147 lines
3.9 KiB
TypeScript

// ABOUTME: Double-tap ESC cancels all running operations (agent stream, subagents, chains, pipelines).
// ABOUTME: Listens for raw terminal ESC input and detects two presses within 400ms.
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { matchesKey } from "@mariozechner/pi-tui";
import { applyExtensionDefaults } from "./lib/themeMap.ts";
/** Time window (ms) for two ESC presses to be considered a double-tap. */
const DOUBLE_TAP_WINDOW = 400;
export default function (pi: ExtensionAPI) {
let lastEscTime = 0;
let unsub: (() => void) | null = null;
let isAgentRunning = false;
function cancelAll(ctx: any) {
const g = globalThis as any;
let cancelled = false;
// 1. Abort the main agent stream
if (!ctx.isIdle()) {
ctx.abort();
cancelled = true;
}
// 2. Kill all running subagents (exposed by subagent-widget.ts)
if (typeof g.__piKillAllSubagents === "function") {
const killed = g.__piKillAllSubagents();
if (killed > 0) cancelled = true;
}
// 3. Kill running chain process (exposed by agent-chain.ts)
if (typeof g.__piKillChainProc === "function") {
if (g.__piKillChainProc()) cancelled = true;
}
// 4. Kill running pipeline processes (exposed by pipeline-team.ts)
if (typeof g.__piKillPipelineProc === "function") {
if (g.__piKillPipelineProc()) cancelled = true;
}
// 5. Kill running team agent processes (exposed by agent-team.ts)
if (typeof g.__piKillTeamProcs === "function") {
const killed = g.__piKillTeamProcs();
if (killed > 0) cancelled = true;
}
if (cancelled) {
ctx.ui.notify("All operations cancelled (ESC ESC)", "warning");
}
}
function setupInputListener(ctx: any) {
if (unsub) return; // Already listening
unsub = ctx.ui.onTerminalInput((data: string) => {
// Only detect bare ESC key
if (!matchesKey(data, "escape")) return undefined;
const now = Date.now();
if (now - lastEscTime < DOUBLE_TAP_WINDOW) {
// Double-tap detected
lastEscTime = 0;
// Only cancel if something is actually running
if (!ctx.isIdle() || hasRunningOperations()) {
cancelAll(ctx);
return { consume: true };
}
} else {
lastEscTime = now;
}
// Don't consume — let the normal ESC handler work
return undefined;
});
}
/** Check if there are running subagents, chains, or pipelines. */
function hasRunningOperations(): boolean {
const g = globalThis as any;
// Check subagents
if (typeof g.__piHasRunningSubagents === "function" && g.__piHasRunningSubagents()) {
return true;
}
// Check chain
if (g.__piActiveChain && typeof g.__piHasRunningChain === "function" && g.__piHasRunningChain()) {
return true;
}
// Check pipeline
if (g.__piActivePipeline && typeof g.__piHasRunningPipeline === "function" && g.__piHasRunningPipeline()) {
return true;
}
// Check team
if (typeof g.__piHasRunningTeam === "function" && g.__piHasRunningTeam()) {
return true;
}
return false;
}
// ── Track agent state for status hint ─────────────────
pi.on("agent_start", async (_event, ctx) => {
isAgentRunning = true;
if (ctx.hasUI) {
ctx.ui.setStatus("esc-hint", "\x1b[2m ESC ESC to cancel\x1b[0m");
}
});
pi.on("agent_end", async (_event, ctx) => {
isAgentRunning = false;
if (ctx.hasUI) {
ctx.ui.setStatus("esc-hint", undefined);
}
});
// ── Session lifecycle ─────────────────────────────────
pi.on("session_start", async (_event, ctx) => {
applyExtensionDefaults(import.meta.url, ctx);
lastEscTime = 0;
isAgentRunning = false;
if (ctx.hasUI) {
setupInputListener(ctx);
}
});
pi.on("session_switch", async (_event, ctx) => {
lastEscTime = 0;
isAgentRunning = false;
if (ctx.hasUI) {
ctx.ui.setStatus("esc-hint", undefined);
}
});
pi.on("session_shutdown", async () => {
if (unsub) {
unsub();
unsub = null;
}
});
}