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; shouldBypassAutocompleteOnEscape?: () => boolean; onClear?: () => void; onExit?: () => void; onSuspend?: () => void; onCycleThinkingLevel?: () => void; onCycleModelForward?: () => void; onCycleModelBackward?: () => void; onSelectModelTemporary?: () => void; onSelectModel?: () => void; onHistorySearch?: () => void; onShowHotkeys?: () => void; onPasteImage?: () => Promise; 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 = { "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(), 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( this: InteractiveModeContext, text: string, fn: () => Promise, options?: { imageCount?: number }, ): Promise { 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); }); });