27f4965e00
When the autocomplete popup is visible, ESC unconditionally falls through to the editor base class so it can dismiss the popup. Only an ESC with no popup visible reaches onEscape and routes to the global interrupt handler. Removed the shouldBypassAutocompleteOnEscape callback that paved over the popup-dismissal path whenever the agent was busy (streaming, bash, /btw, auto-compaction, etc.) — that is precisely the moment the user is most likely to hit ESC while the popup is open, and dismissing-then-aborting in one keystroke is a footgun, not a feature. Two-press semantics now match the standard TUI/IDE pattern: first ESC closes the popup, second ESC aborts. Tests cover both branches in custom-editor-keybindings.test.ts and the input-controller escape suite no longer asserts the dead callback. Fixes #1655
318 lines
10 KiB
TypeScript
318 lines
10 KiB
TypeScript
import { describe, expect, it, type Mock, vi } from "bun:test";
|
|
import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller";
|
|
import type { InteractiveModeContext, SubmittedUserInput } from "@oh-my-pi/pi-coding-agent/modes/types";
|
|
|
|
type Spy = Mock<(...args: unknown[]) => unknown>;
|
|
type StartPendingSubmissionSpy = Mock<InteractiveModeContext["startPendingSubmission"]>;
|
|
type FakeEditor = {
|
|
onEscape?: () => void;
|
|
onSubmit?: (text: string) => Promise<void>;
|
|
onClear?: () => void;
|
|
onExit?: () => void;
|
|
onSuspend?: () => void;
|
|
onCycleThinkingLevel?: () => void;
|
|
onCycleModelForward?: () => void;
|
|
onCycleModelBackward?: () => void;
|
|
onSelectModelTemporary?: () => void;
|
|
onSelectModel?: () => void;
|
|
onHistorySearch?: () => void;
|
|
onPasteImage?: () => void;
|
|
onCopyPrompt?: () => void;
|
|
onExpandTools?: () => void;
|
|
onToggleThinking?: () => void;
|
|
onExternalEditor?: () => void;
|
|
onDequeue?: () => void;
|
|
onChange?: (text: string) => void;
|
|
setText(text: string): void;
|
|
getText(): string;
|
|
addToHistory(text: string): void;
|
|
setActionKeys(action: string, keys: string[]): void;
|
|
setCustomKeyHandler(key: string, handler: () => void): void;
|
|
clearCustomKeyHandlers(): void;
|
|
};
|
|
|
|
function createSubmission(input: {
|
|
text: string;
|
|
images?: InteractiveModeContext["pendingImages"];
|
|
}): SubmittedUserInput {
|
|
return {
|
|
text: input.text,
|
|
images: input.images,
|
|
cancelled: false,
|
|
started: false,
|
|
};
|
|
}
|
|
|
|
function createContext(): {
|
|
ctx: InteractiveModeContext;
|
|
editor: FakeEditor;
|
|
spies: {
|
|
abort: Spy;
|
|
abortBash: Spy;
|
|
abortEval: Spy;
|
|
addMessageToChat: Spy;
|
|
cancelPendingSubmission: Spy;
|
|
clearQueue: Spy;
|
|
ensureLoadingAnimation: Spy;
|
|
handleBtwCommand: Spy;
|
|
handleBtwEscape: Spy;
|
|
handleOmfgEscape: Spy;
|
|
hasActiveBtw: Spy;
|
|
hasActiveOmfg: Spy;
|
|
onInputCallback: Spy;
|
|
prompt: Spy;
|
|
requestRender: Spy;
|
|
startPendingSubmission: StartPendingSubmissionSpy;
|
|
};
|
|
} {
|
|
let editorText = "";
|
|
const abort = vi.fn();
|
|
const abortBash = vi.fn();
|
|
const abortEval = vi.fn();
|
|
const addMessageToChat = vi.fn();
|
|
const cancelPendingSubmission = vi.fn(() => false);
|
|
const clearQueue = vi.fn(() => ({ steering: [], followUp: [] }));
|
|
const onInputCallback = vi.fn();
|
|
const prompt = vi.fn();
|
|
const requestRender = vi.fn();
|
|
const handleBtwCommand = vi.fn(async () => {});
|
|
const handleBtwEscape = vi.fn(() => true);
|
|
const hasActiveBtw = vi.fn(() => false);
|
|
const handleOmfgEscape = vi.fn(() => true);
|
|
const hasActiveOmfg = vi.fn(() => false);
|
|
const startPendingSubmission = vi.fn((input: { text: string; images?: InteractiveModeContext["pendingImages"] }) => {
|
|
ensureLoadingAnimation();
|
|
return createSubmission(input);
|
|
});
|
|
const editor: FakeEditor = {
|
|
setText(text: string) {
|
|
editorText = text;
|
|
},
|
|
getText() {
|
|
return editorText;
|
|
},
|
|
addToHistory: vi.fn(),
|
|
setActionKeys: vi.fn(),
|
|
setCustomKeyHandler: vi.fn(),
|
|
clearCustomKeyHandlers: vi.fn(),
|
|
};
|
|
|
|
let ctx!: InteractiveModeContext;
|
|
const ensureLoadingAnimation = vi.fn(() => {
|
|
ctx.loadingAnimation = {} as InteractiveModeContext["loadingAnimation"];
|
|
});
|
|
|
|
ctx = {
|
|
editor: editor as unknown as InteractiveModeContext["editor"],
|
|
ui: { requestRender } as unknown as InteractiveModeContext["ui"],
|
|
loadingAnimation: undefined,
|
|
autoCompactionLoader: undefined,
|
|
retryLoader: undefined,
|
|
autoCompactionEscapeHandler: undefined,
|
|
retryEscapeHandler: undefined,
|
|
session: {
|
|
isStreaming: false,
|
|
isCompacting: false,
|
|
isGeneratingHandoff: false,
|
|
isBashRunning: false,
|
|
isEvalRunning: false,
|
|
queuedMessageCount: 0,
|
|
messages: [],
|
|
extensionRunner: undefined,
|
|
abort,
|
|
abortBash,
|
|
abortEval,
|
|
clearQueue,
|
|
prompt,
|
|
} as unknown as InteractiveModeContext["session"],
|
|
sessionManager: {
|
|
getSessionName: () => "existing session",
|
|
} as unknown as InteractiveModeContext["sessionManager"],
|
|
keybindings: {
|
|
getKeys: () => [],
|
|
} as unknown as InteractiveModeContext["keybindings"],
|
|
pendingImages: [],
|
|
isBashMode: false,
|
|
isPythonMode: false,
|
|
optimisticUserMessageSignature: undefined,
|
|
locallySubmittedUserSignatures: new Set<string>(),
|
|
onInputCallback,
|
|
addMessageToChat,
|
|
cancelPendingSubmission,
|
|
ensureLoadingAnimation,
|
|
finishPendingSubmission: vi.fn(),
|
|
flushPendingBashComponents: vi.fn(),
|
|
markPendingSubmissionStarted: vi.fn(() => true),
|
|
startPendingSubmission,
|
|
updatePendingMessagesDisplay: vi.fn(),
|
|
updateEditorBorderColor: vi.fn(),
|
|
showDebugSelector: vi.fn(),
|
|
toggleTodoExpansion: vi.fn(),
|
|
handleHotkeysCommand: vi.fn(),
|
|
handleSTTToggle: vi.fn(),
|
|
handleBtwEscape,
|
|
handleBtwCommand,
|
|
hasActiveBtw,
|
|
handleOmfgEscape,
|
|
hasActiveOmfg,
|
|
showTreeSelector: vi.fn(),
|
|
showUserMessageSelector: vi.fn(),
|
|
showSessionSelector: vi.fn(),
|
|
} as unknown as InteractiveModeContext;
|
|
|
|
return {
|
|
ctx,
|
|
editor,
|
|
spies: {
|
|
abort,
|
|
abortBash,
|
|
abortEval,
|
|
addMessageToChat,
|
|
cancelPendingSubmission,
|
|
clearQueue,
|
|
ensureLoadingAnimation,
|
|
handleBtwCommand,
|
|
handleBtwEscape,
|
|
hasActiveBtw,
|
|
handleOmfgEscape,
|
|
hasActiveOmfg,
|
|
onInputCallback,
|
|
prompt,
|
|
requestRender,
|
|
startPendingSubmission,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("InputController escape behavior", () => {
|
|
it("prefers canceling a pending optimistic submission before aborting the session", async () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
const submission = createSubmission({ text: "hello" });
|
|
spies.startPendingSubmission.mockReturnValue(submission);
|
|
spies.cancelPendingSubmission.mockReturnValue(true);
|
|
ctx.loadingAnimation = {} as InteractiveModeContext["loadingAnimation"];
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupKeyHandlers();
|
|
controller.setupEditorSubmitHandler();
|
|
await editor.onSubmit?.("hello");
|
|
|
|
expect(spies.startPendingSubmission).toHaveBeenCalledWith({ text: "hello", images: undefined });
|
|
expect(spies.onInputCallback).toHaveBeenCalledWith(submission);
|
|
|
|
editor.onEscape?.();
|
|
expect(spies.cancelPendingSubmission).toHaveBeenCalledTimes(1);
|
|
expect(spies.clearQueue).not.toHaveBeenCalled();
|
|
expect(spies.abort).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("runs /btw as a builtin side request instead of steering the active stream", async () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
(ctx.session as { isStreaming: boolean }).isStreaming = true;
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupEditorSubmitHandler();
|
|
editor.setText("/btw why is it doing that?");
|
|
await editor.onSubmit?.("/btw why is it doing that?");
|
|
|
|
expect(spies.handleBtwCommand).toHaveBeenCalledWith("why is it doing that?");
|
|
expect(spies.prompt).not.toHaveBeenCalled();
|
|
expect(editor.addToHistory).not.toHaveBeenCalled();
|
|
expect(editor.getText()).toBe("");
|
|
});
|
|
|
|
it("falls back to aborting the active session when no pending optimistic submission exists", () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
ctx.loadingAnimation = {} as InteractiveModeContext["loadingAnimation"];
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupKeyHandlers();
|
|
editor.onEscape?.();
|
|
|
|
expect(spies.cancelPendingSubmission).toHaveBeenCalledTimes(1);
|
|
expect(spies.clearQueue).toHaveBeenCalledTimes(1);
|
|
expect(spies.abort).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("prefers aborting bash before aborting an overlapping stream", () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
(ctx.session as { isStreaming: boolean; isBashRunning: boolean }).isStreaming = true;
|
|
(ctx.session as { isStreaming: boolean; isBashRunning: boolean }).isBashRunning = true;
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupKeyHandlers();
|
|
editor.onEscape?.();
|
|
|
|
expect(spies.abortBash).toHaveBeenCalledTimes(1);
|
|
expect(spies.abort).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("prefers aborting python before aborting an overlapping stream", () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
(ctx.session as { isStreaming: boolean; isEvalRunning: boolean }).isStreaming = true;
|
|
(ctx.session as { isStreaming: boolean; isEvalRunning: boolean }).isEvalRunning = true;
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupKeyHandlers();
|
|
editor.onEscape?.();
|
|
|
|
expect(spies.abortEval).toHaveBeenCalledTimes(1);
|
|
expect(spies.abort).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("dismisses an active /btw panel before aborting the main stream", () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
(ctx.session as { isStreaming: boolean }).isStreaming = true;
|
|
spies.hasActiveBtw.mockReturnValue(true);
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupKeyHandlers();
|
|
editor.onEscape?.();
|
|
|
|
expect(spies.handleBtwEscape).toHaveBeenCalledTimes(1);
|
|
expect(spies.abort).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("dismisses an active /btw panel before canceling a pending optimistic submission", () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
ctx.loadingAnimation = {} as InteractiveModeContext["loadingAnimation"];
|
|
spies.hasActiveBtw.mockReturnValue(true);
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupKeyHandlers();
|
|
editor.onEscape?.();
|
|
|
|
expect(spies.handleBtwEscape).toHaveBeenCalledTimes(1);
|
|
expect(spies.cancelPendingSubmission).not.toHaveBeenCalled();
|
|
expect(spies.clearQueue).not.toHaveBeenCalled();
|
|
expect(spies.abort).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("dismisses an active /btw panel before aborting bash", () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
(ctx.session as { isBashRunning: boolean }).isBashRunning = true;
|
|
spies.hasActiveBtw.mockReturnValue(true);
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupKeyHandlers();
|
|
editor.onEscape?.();
|
|
|
|
expect(spies.handleBtwEscape).toHaveBeenCalledTimes(1);
|
|
expect(spies.abortBash).not.toHaveBeenCalled();
|
|
expect(spies.abort).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("aborts streaming even when the working loader is no longer present", () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
(ctx.session as { isStreaming: boolean }).isStreaming = true;
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupKeyHandlers();
|
|
editor.onEscape?.();
|
|
|
|
expect(spies.cancelPendingSubmission).not.toHaveBeenCalled();
|
|
expect(spies.clearQueue).not.toHaveBeenCalled();
|
|
expect(spies.abort).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|