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
214 lines
7.0 KiB
TypeScript
214 lines
7.0 KiB
TypeScript
import { describe, expect, it, vi } from "bun:test";
|
|
import { InputController } from "../src/modes/controllers/input-controller";
|
|
import type { InteractiveModeContext } from "../src/modes/types";
|
|
|
|
type FakeEditor = {
|
|
onEscape?: () => void;
|
|
onClear?: () => void;
|
|
onExit?: () => void;
|
|
onSuspend?: () => void;
|
|
onCycleThinkingLevel?: () => void;
|
|
onCycleModelForward?: () => void;
|
|
onCycleModelBackward?: () => void;
|
|
onSelectModelTemporary?: () => void;
|
|
onSelectModel?: () => void;
|
|
onHistorySearch?: () => void;
|
|
onPasteImage?: () => Promise<boolean>;
|
|
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;
|
|
};
|
|
|
|
async function createContext() {
|
|
let editorText = "";
|
|
const keyMap: Record<string, string[]> = {
|
|
"app.model.selectTemporary": ["ctrl+y"],
|
|
"app.model.select": ["ctrl+l"],
|
|
};
|
|
const setActionKeys = vi.fn();
|
|
const showModelSelector = vi.fn();
|
|
const prompt = vi.fn(async () => {});
|
|
const updatePendingMessagesDisplay = vi.fn();
|
|
const editor: FakeEditor = {
|
|
setText(text: string) {
|
|
editorText = text;
|
|
},
|
|
getText() {
|
|
return editorText;
|
|
},
|
|
addToHistory: vi.fn(),
|
|
setActionKeys,
|
|
setCustomKeyHandler: vi.fn(),
|
|
clearCustomKeyHandlers: vi.fn(),
|
|
};
|
|
const ctx = {
|
|
editor: editor as unknown as InteractiveModeContext["editor"],
|
|
ui: { requestRender: vi.fn() } 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,
|
|
extensionRunner: undefined,
|
|
prompt,
|
|
} as unknown as InteractiveModeContext["session"],
|
|
keybindings: {
|
|
getKeys(action: string) {
|
|
return keyMap[action] ? [...keyMap[action]] : [];
|
|
},
|
|
} as InteractiveModeContext["keybindings"],
|
|
pendingImages: [],
|
|
locallySubmittedUserSignatures: new Set<string>(),
|
|
isKnownSlashCommand: () => false,
|
|
recordLocalSubmission(this: InteractiveModeContext, text: string, imageCount = 0) {
|
|
if (this.isKnownSlashCommand(text)) return () => {};
|
|
const sig = `${text}\u0000${imageCount}`;
|
|
this.locallySubmittedUserSignatures.add(sig);
|
|
let disposed = false;
|
|
return () => {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
this.locallySubmittedUserSignatures.delete(sig);
|
|
};
|
|
},
|
|
async withLocalSubmission<T>(
|
|
this: InteractiveModeContext,
|
|
text: string,
|
|
fn: () => Promise<T>,
|
|
options?: { imageCount?: number },
|
|
): Promise<T> {
|
|
const dispose = this.recordLocalSubmission(text, options?.imageCount ?? 0);
|
|
try {
|
|
return await fn();
|
|
} catch (err) {
|
|
dispose();
|
|
throw err;
|
|
}
|
|
},
|
|
updatePendingMessagesDisplay,
|
|
isBashMode: false,
|
|
isPythonMode: false,
|
|
handleHotkeysCommand: vi.fn(),
|
|
handlePlanModeCommand: vi.fn(),
|
|
handleClearCommand: vi.fn(),
|
|
showTreeSelector: vi.fn(),
|
|
showUserMessageSelector: vi.fn(),
|
|
showSessionSelector: vi.fn(),
|
|
handleSTTToggle: vi.fn(),
|
|
showDebugSelector: vi.fn(),
|
|
showHistorySearch: vi.fn(),
|
|
toggleThinkingBlockVisibility: vi.fn(),
|
|
showModelSelector,
|
|
updateEditorBorderColor: vi.fn(),
|
|
hasActiveBtw: vi.fn(() => false),
|
|
} as unknown as InteractiveModeContext;
|
|
|
|
return {
|
|
InputController,
|
|
ctx,
|
|
editor,
|
|
spies: {
|
|
setActionKeys,
|
|
showModelSelector,
|
|
prompt,
|
|
updatePendingMessagesDisplay,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("InputController keybinding setup", () => {
|
|
it("registers temporary and persisted model selector actions separately", async () => {
|
|
const { InputController, ctx, editor, spies } = await createContext();
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupKeyHandlers();
|
|
|
|
expect(spies.setActionKeys).toHaveBeenCalledWith("app.model.selectTemporary", ["ctrl+y"]);
|
|
expect(spies.setActionKeys).toHaveBeenCalledWith("app.model.select", ["ctrl+l"]);
|
|
expect(editor.onSelectModelTemporary).toBeDefined();
|
|
expect(editor.onSelectModel).toBeDefined();
|
|
expect(editor.onSelectModelTemporary).not.toBe(editor.onSelectModel);
|
|
|
|
editor.onSelectModelTemporary?.();
|
|
editor.onSelectModel?.();
|
|
|
|
expect(spies.showModelSelector).toHaveBeenNthCalledWith(1, { temporaryOnly: true });
|
|
expect(spies.showModelSelector).toHaveBeenNthCalledWith(2);
|
|
});
|
|
|
|
it("marks streaming follow-up submissions as local", async () => {
|
|
const { InputController, ctx, editor, spies } = await createContext();
|
|
const session = ctx.session as unknown as { isStreaming: boolean };
|
|
session.isStreaming = true;
|
|
editor.setText("follow up after current response");
|
|
const controller = new InputController(ctx);
|
|
|
|
await controller.handleFollowUp();
|
|
|
|
expect(ctx.locallySubmittedUserSignatures.has("follow up after current response\u00000")).toBe(true);
|
|
expect(spies.prompt).toHaveBeenCalledWith("follow up after current response", {
|
|
streamingBehavior: "followUp",
|
|
});
|
|
expect(spies.updatePendingMessagesDisplay).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("marks idle follow-up submissions as local", async () => {
|
|
const { InputController, ctx, editor, spies } = await createContext();
|
|
// Default fake session is idle.
|
|
editor.setText("plain idle submit");
|
|
const controller = new InputController(ctx);
|
|
|
|
await controller.handleFollowUp();
|
|
|
|
expect(ctx.locallySubmittedUserSignatures.has("plain idle submit\u00000")).toBe(true);
|
|
// Idle submit calls prompt() with no streamingBehavior.
|
|
expect(spies.prompt).toHaveBeenCalledWith("plain idle submit");
|
|
});
|
|
|
|
it("removes the signature when an idle follow-up submission rejects", async () => {
|
|
const { InputController, ctx, editor, spies } = await createContext();
|
|
spies.prompt.mockImplementationOnce(async () => {
|
|
throw new Error("boom");
|
|
});
|
|
editor.setText("doomed submit");
|
|
const controller = new InputController(ctx);
|
|
|
|
await expect(controller.handleFollowUp()).rejects.toThrow("boom");
|
|
|
|
// Contract: a thrown delivery error must not leave a stale signature
|
|
// behind, otherwise the next attempt with the same text would silently
|
|
// suppress the editor-clear protection that was meant for the failed call.
|
|
expect(ctx.locallySubmittedUserSignatures.has("doomed submit\u00000")).toBe(false);
|
|
});
|
|
|
|
it("removes the signature when a streaming follow-up rejects", async () => {
|
|
const { InputController, ctx, editor, spies } = await createContext();
|
|
const session = ctx.session as unknown as { isStreaming: boolean };
|
|
session.isStreaming = true;
|
|
spies.prompt.mockImplementationOnce(async () => {
|
|
throw new Error("queue full");
|
|
});
|
|
editor.setText("queued during stream");
|
|
const controller = new InputController(ctx);
|
|
|
|
await expect(controller.handleFollowUp()).rejects.toThrow("queue full");
|
|
|
|
expect(ctx.locallySubmittedUserSignatures.has("queued during stream\u00000")).toBe(false);
|
|
});
|
|
});
|