import { describe, expect, it, type Mock, vi } from "bun:test"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; import manualContinuePrompt from "../src/prompts/system/manual-continue.md" with { type: "text" }; type FakeEditor = { onEscape?: () => void; onClear?: () => void; onExit?: () => void; onDisplayReset?: () => void; onSuspend?: () => void; onCycleThinkingLevel?: () => void; onCycleModelForward?: () => void; onCycleModelBackward?: () => void; onSelectModelTemporary?: () => void; onSelectModel?: () => void; onHistorySearch?: () => void; onPasteImage?: () => Promise; onCopyPrompt?: () => void; onExpandTools?: () => void; onToggleThinking?: () => void; onExternalEditor?: () => void; onRetry?: () => void; onChange?: (text: string) => void; onSubmit?: (text: string) => Promise; setText(text: string): void; getText(): string; getExpandedText(): string; addToHistory(text: string): void; setActionKeys(action: string, keys: string[]): void; setCustomKeyHandler(key: string, handler: () => void): void; clearCustomKeyHandlers(): void; pasteText(text: string): void; imageLinks?: (string | undefined)[]; pendingImages: ImageContent[]; pendingImageLinks: (string | undefined)[]; clearDraft(historyText?: string): void; }; type InputListenerResult = { consume: boolean } | undefined; type InputListener = (data: string) => InputListenerResult; function dispatchInput(listeners: InputListener[], data: string): InputListenerResult { for (const listener of listeners) { const result = listener(data); if (result) return result; } return undefined; } function registeredInputListeners(addInputListener: Mock<(listener: InputListener) => void>): InputListener[] { return addInputListener.mock.calls.map(call => call[0]); } async function createContext() { let editorText = ""; const keyMap: Record = { "app.display.reset": ["ctrl+l"], "app.model.selectTemporary": ["ctrl+y"], "app.model.select": ["alt+m"], "app.retry": ["alt+r"], }; const customHandlers = new Map void>(); const setActionKeys = vi.fn(); const setCustomKeyHandler = vi.fn((key: string, handler: () => void) => { customHandlers.set(key, handler); }); const clearCustomKeyHandlers = vi.fn(() => { customHandlers.clear(); }); const resetDisplay = vi.fn(); const showModelSelector = vi.fn(); const requestRender = vi.fn(); const showError = vi.fn(); let focused: unknown; const addInputListener = vi.fn((listener: InputListener) => { void listener; }); const addStartListener = vi.fn(); const terminalWrite = vi.fn(); const prompt = vi.fn(async () => {}); const retry = vi.fn(async () => true); const abort = vi.fn(async () => {}); const session = { isStreaming: false, isCompacting: false, isGeneratingHandoff: false, isBashRunning: false, isEvalRunning: false, extensionRunner: undefined, prompt, queuedMessageCount: 0, abort, retry, }; const updatePendingMessagesDisplay = vi.fn(); const handleBtwBranchKey = vi.fn(async () => true); const handleBtwCopyKey = vi.fn(async () => true); const canBranchBtw = vi.fn(() => false); const canCopyBtw = vi.fn(() => false); const editor: FakeEditor = { setText(text: string) { editorText = text; }, getText() { return editorText; }, getExpandedText() { return editorText; }, addToHistory: vi.fn(), pasteText(text: string) { editorText += text; }, setActionKeys, setCustomKeyHandler, clearCustomKeyHandlers, pendingImages: [], pendingImageLinks: [], clearDraft(historyText?: string) { if (historyText !== undefined) this.addToHistory(historyText); this.setText(""); this.imageLinks = undefined; this.pendingImages = []; this.pendingImageLinks = []; }, }; focused = editor; const ctx = { editor: editor as unknown as InteractiveModeContext["editor"], ui: { requestRender, resetDisplay, addInputListener, addStartListener, getFocused: vi.fn(() => focused), terminal: { write: terminalWrite }, } as unknown as InteractiveModeContext["ui"], loadingAnimation: undefined, autoCompactionLoader: undefined, retryLoader: undefined, autoCompactionEscapeHandler: undefined, retryEscapeHandler: undefined, session: session as unknown as InteractiveModeContext["session"], viewSession: session as unknown as InteractiveModeContext["viewSession"], keybindings: { getKeys(action: string) { return keyMap[action] ? [...keyMap[action]] : []; }, } as InteractiveModeContext["keybindings"], 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), handleBtwBranchKey, canBranchBtw, canCopyBtw, handleBtwCopyKey, showError, showStatus: vi.fn(), } as unknown as InteractiveModeContext; return { InputController, ctx, editor, customHandlers, setFocused(target: unknown) { focused = target; }, spies: { setActionKeys, showModelSelector, prompt, updatePendingMessagesDisplay, requestRender, retry, abort, resetDisplay, handleBtwBranchKey, addInputListener, canBranchBtw, handleBtwCopyKey, canCopyBtw, showError, }, }; } describe("InputController keybinding setup", () => { it("registers model selector and display reset actions separately", async () => { const { InputController, ctx, editor, spies } = await createContext(); const controller = new InputController(ctx); controller.setupKeyHandlers(); expect(spies.setActionKeys).toHaveBeenCalledWith("app.display.reset", ["ctrl+l"]); expect(spies.setActionKeys).toHaveBeenCalledWith("app.model.selectTemporary", ["ctrl+y"]); expect(spies.setActionKeys).toHaveBeenCalledWith("app.model.select", ["alt+m"]); expect(editor.onDisplayReset).toBeDefined(); expect(editor.onSelectModelTemporary).toBeDefined(); expect(editor.onSelectModel).toBeDefined(); expect(editor.onSelectModelTemporary).not.toBe(editor.onSelectModel); editor.onDisplayReset?.(); editor.onSelectModelTemporary?.(); editor.onSelectModel?.(); expect(spies.showModelSelector).toHaveBeenNthCalledWith(1, { temporaryOnly: true }); expect(spies.showModelSelector).toHaveBeenNthCalledWith(2); expect(spies.resetDisplay).toHaveBeenCalledTimes(1); }); it("does not mark pasted shell prompts as Python mode while editing", async () => { const { InputController, ctx, editor } = await createContext(); const controller = new InputController(ctx); controller.setupKeyHandlers(); editor.onChange?.("$ cd ~/project && sudo ./build-and-push.sh o5.7 2>&1 | tail -4"); expect(ctx.isPythonMode).toBe(false); expect(ctx.updateEditorBorderColor).not.toHaveBeenCalled(); editor.onChange?.("$ print(1)"); expect(ctx.isPythonMode).toBe(true); expect(ctx.updateEditorBorderColor).toHaveBeenCalledTimes(1); }); it("registers retry as an editor action and retries the failed turn", async () => { const { InputController, ctx, editor, spies } = await createContext(); const controller = new InputController(ctx); controller.setupKeyHandlers(); expect(spies.setActionKeys).toHaveBeenCalledWith("app.retry", ["alt+r"]); expect(editor.onRetry).toBeDefined(); editor.setText("draft that should clear after retry"); editor.onRetry?.(); await Promise.resolve(); expect(spies.retry).toHaveBeenCalledTimes(1); expect(editor.getText()).toBe(""); }); it("retries the focused view session instead of the main session", async () => { const { InputController, ctx, editor, spies } = await createContext(); const focusedRetry = vi.fn(async () => true); (ctx as unknown as { focusedAgentId: string; viewSession: { retry: typeof focusedRetry } }).focusedAgentId = "worker"; (ctx as unknown as { viewSession: { retry: typeof focusedRetry } }).viewSession = { retry: focusedRetry }; const controller = new InputController(ctx); controller.setupKeyHandlers(); editor.onRetry?.(); await Promise.resolve(); expect(focusedRetry).toHaveBeenCalledTimes(1); expect(spies.retry).not.toHaveBeenCalled(); }); it("keeps retry host-only for collab guests", async () => { const { InputController, ctx, editor, spies } = await createContext(); const showStatus = ctx.showStatus as unknown as Mock<(message: string) => void>; (ctx as unknown as { collabGuest: { readOnly: boolean } }).collabGuest = { readOnly: true }; const controller = new InputController(ctx); controller.setupKeyHandlers(); editor.setText("guest draft"); editor.onRetry?.(); await Promise.resolve(); expect(spies.retry).not.toHaveBeenCalled(); expect(showStatus).toHaveBeenCalledWith("/retry is host-only during a collab session"); expect(editor.getText()).toBe("guest draft"); }); it("keeps the draft when there is nothing to retry", async () => { const { InputController, ctx, editor, spies } = await createContext(); spies.retry.mockResolvedValueOnce(false); const showStatus = ctx.showStatus as unknown as Mock<(message: string) => void>; const controller = new InputController(ctx); controller.setupKeyHandlers(); editor.setText("draft that should survive"); editor.onRetry?.(); await Promise.resolve(); expect(showStatus).toHaveBeenCalledWith("Nothing to retry"); expect(editor.getText()).toBe("draft that should survive"); }); it("clears retry draft attachments only after retry starts", async () => { const { InputController, ctx, editor } = await createContext(); const image: ImageContent = { type: "image", mimeType: "image/png", data: "abc" }; const controller = new InputController(ctx); controller.setupKeyHandlers(); ctx.editor.pendingImages = [image]; ctx.editor.pendingImageLinks = ["local://draft.png"]; editor.imageLinks = ctx.editor.pendingImageLinks; editor.setText("draft with image"); editor.onRetry?.(); await Promise.resolve(); expect(ctx.editor.pendingImages).toEqual([]); expect(ctx.editor.pendingImageLinks).toEqual([]); expect(editor.imageLinks).toBeUndefined(); expect(editor.getText()).toBe(""); }); it("routes b to branch a branchable /btw panel", async () => { const { InputController, ctx, spies } = await createContext(); (ctx.canBranchBtw as unknown as { mockReturnValue(value: boolean): void }).mockReturnValue(true); const controller = new InputController(ctx); controller.setupKeyHandlers(); const listener = spies.addInputListener.mock.calls[1]?.[0]; expect(listener).toBeDefined(); const result = listener?.("b"); expect(result).toEqual({ consume: true }); expect(spies.handleBtwBranchKey).toHaveBeenCalledTimes(1); }); it("lets b fall through while the editor has draft text", async () => { const { InputController, ctx, editor, spies } = await createContext(); (ctx.canBranchBtw as unknown as { mockReturnValue(value: boolean): void }).mockReturnValue(true); editor.setText("build a branch"); const controller = new InputController(ctx); controller.setupKeyHandlers(); const listener = spies.addInputListener.mock.calls[1]?.[0]; expect(listener).toBeDefined(); const result = listener?.("b"); expect(result).toBeUndefined(); expect(spies.handleBtwBranchKey).not.toHaveBeenCalled(); }); it("lets b fall through when /btw is not branchable", async () => { const { InputController, ctx, spies } = await createContext(); const controller = new InputController(ctx); controller.setupKeyHandlers(); const listener = spies.addInputListener.mock.calls[1]?.[0]; expect(listener).toBeDefined(); const result = listener?.("b"); expect(result).toBeUndefined(); expect(spies.handleBtwBranchKey).not.toHaveBeenCalled(); }); it("lets b fall through while another input is focused", async () => { const { InputController, ctx, setFocused, spies } = await createContext(); (ctx.canBranchBtw as unknown as { mockReturnValue(value: boolean): void }).mockReturnValue(true); setFocused({ pasteText: vi.fn() }); const controller = new InputController(ctx); controller.setupKeyHandlers(); const result = dispatchInput(registeredInputListeners(spies.addInputListener), "b"); expect(result).toBeUndefined(); expect(spies.handleBtwBranchKey).not.toHaveBeenCalled(); }); it("routes c to copy a copyable /btw panel when the editor is empty", async () => { const { InputController, ctx, spies } = await createContext(); (ctx.canCopyBtw as unknown as { mockReturnValue(value: boolean): void }).mockReturnValue(true); const controller = new InputController(ctx); controller.setupKeyHandlers(); const result = dispatchInput(registeredInputListeners(spies.addInputListener), "c"); expect(result).toEqual({ consume: true }); expect(spies.handleBtwCopyKey).toHaveBeenCalledTimes(1); }); it("lets c fall through while the editor has draft text", async () => { const { InputController, ctx, editor, spies } = await createContext(); (ctx.canCopyBtw as unknown as { mockReturnValue(value: boolean): void }).mockReturnValue(true); editor.setText("continue this draft"); const controller = new InputController(ctx); controller.setupKeyHandlers(); const result = dispatchInput(registeredInputListeners(spies.addInputListener), "c"); expect(result).toBeUndefined(); expect(spies.handleBtwCopyKey).not.toHaveBeenCalled(); }); it("lets c fall through when /btw is not copyable", async () => { const { InputController, ctx, spies } = await createContext(); const controller = new InputController(ctx); controller.setupKeyHandlers(); const result = dispatchInput(registeredInputListeners(spies.addInputListener), "c"); expect(result).toBeUndefined(); expect(spies.handleBtwCopyKey).not.toHaveBeenCalled(); }); it("lets c fall through while another input is focused", async () => { const { InputController, ctx, setFocused, spies } = await createContext(); (ctx.canCopyBtw as unknown as { mockReturnValue(value: boolean): void }).mockReturnValue(true); setFocused({ pasteText: vi.fn() }); const controller = new InputController(ctx); controller.setupKeyHandlers(); const result = dispatchInput(registeredInputListeners(spies.addInputListener), "c"); expect(result).toBeUndefined(); expect(spies.handleBtwCopyKey).not.toHaveBeenCalled(); }); it("empty Enter aborts the active stream when queued messages are pending", async () => { const { InputController, ctx, editor, spies } = await createContext(); const session = ctx.session as unknown as { isStreaming: boolean; queuedMessageCount: number }; session.isStreaming = true; session.queuedMessageCount = 1; const controller = new InputController(ctx); controller.setupEditorSubmitHandler(); await editor.onSubmit?.(""); expect(spies.abort).toHaveBeenCalledWith({ reason: "Interrupted by user" }); expect(spies.updatePendingMessagesDisplay).toHaveBeenCalledTimes(1); expect(spies.requestRender).toHaveBeenCalledTimes(1); expect(spies.prompt).not.toHaveBeenCalled(); }); 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 (images forwarded, undefined here). expect(spies.prompt).toHaveBeenCalledWith("plain idle submit", { images: undefined }); }); it("surfaces and recovers from an idle follow-up dispatch failure", 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); // Dispatch failures are caught and surfaced (mirroring the main/focused // submit paths), not rethrown, so the keybinding's fire-and-forget call // never raises an unhandled rejection. await controller.handleFollowUp(); expect(spies.showError).toHaveBeenCalledWith("boom"); // Draft handed back so the user can retry. expect(editor.getText()).toBe("doomed submit"); // Contract: a failed delivery 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("surfaces and recovers from a streaming follow-up dispatch failure", 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 controller.handleFollowUp(); expect(spies.showError).toHaveBeenCalledWith("queue full"); expect(editor.getText()).toBe("queued during stream"); expect(ctx.locallySubmittedUserSignatures.has("queued during stream\u00000")).toBe(false); }); it("continue shortcuts submit a hidden synthetic developer directive", async () => { for (const shortcut of [".", "c"]) { const { InputController, ctx, editor } = await createContext(); const onInput = vi.fn(); ctx.onInputCallback = onInput; const controller = new InputController(ctx); controller.setupEditorSubmitHandler(); await editor.onSubmit?.(shortcut); expect(onInput, `shortcut ${shortcut}`).toHaveBeenCalledWith({ text: manualContinuePrompt, cancelled: false, started: true, synthetic: true, userInitiated: true, }); } }); });