c938fe4d27
- Added a SessionFocusController to switch transcript and input context between main and subagent sessions. - Added agent-hub Enter activation and double-left return behavior for focused local agents. - Added view-session-based event and render logic to avoid stale focus-session state. - Added status-line focused agent display with ghost icon and focused-mode border dimming.
368 lines
12 KiB
TypeScript
368 lines
12 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";
|
|
import { USER_INTERRUPT_LABEL } from "@oh-my-pi/pi-coding-agent/session/messages";
|
|
|
|
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;
|
|
onLeftAtStart?: () => 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"];
|
|
imageLinks?: InteractiveModeContext["pendingImageLinks"];
|
|
}): SubmittedUserInput {
|
|
return {
|
|
text: input.text,
|
|
images: input.images,
|
|
imageLinks: input.imageLinks,
|
|
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"];
|
|
imageLinks?: InteractiveModeContext["pendingImageLinks"];
|
|
}) => {
|
|
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,
|
|
addInputListener: vi.fn(),
|
|
addStartListener: 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,
|
|
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: [],
|
|
pendingImageLinks: [],
|
|
isBashMode: false,
|
|
isPythonMode: false,
|
|
optimisticUserMessageSignature: undefined,
|
|
locallySubmittedUserSignatures: new Set<string>(),
|
|
onInputCallback,
|
|
addMessageToChat,
|
|
cancelPendingSubmission,
|
|
ensureLoadingAnimation,
|
|
notifyInterrupting: vi.fn(),
|
|
finishPendingSubmission: vi.fn(),
|
|
flushPendingBashComponents: vi.fn(),
|
|
markPendingSubmissionStarted: vi.fn(() => true),
|
|
startPendingSubmission,
|
|
updatePendingMessagesDisplay: vi.fn(),
|
|
updateEditorBorderColor: vi.fn(),
|
|
showDebugSelector: vi.fn(),
|
|
toggleTodoExpansion: vi.fn(),
|
|
showAgentHub: vi.fn(),
|
|
unfocusSession: vi.fn(async () => {}),
|
|
focusParentSession: vi.fn(async () => {}),
|
|
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,
|
|
imageLinks: 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);
|
|
// The Esc interrupt threads a user-facing reason so the aborted turn and its
|
|
// synthetic tool results read as a deliberate interrupt, not "Request was aborted".
|
|
expect(spies.abort).toHaveBeenCalledWith({ reason: USER_INTERRUPT_LABEL });
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it("returns focused subagent view to main on Esc instead of aborting", () => {
|
|
const { ctx, editor, spies } = createContext();
|
|
Object.defineProperty(ctx, "focusedAgentId", { value: "Worker", configurable: true });
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupKeyHandlers();
|
|
editor.onEscape?.();
|
|
|
|
expect(ctx.unfocusSession).toHaveBeenCalledTimes(1);
|
|
expect(spies.abort).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("routes focused left-left through the same main-return path as Esc", () => {
|
|
const { ctx, editor } = createContext();
|
|
Object.defineProperty(ctx, "focusedAgentId", { value: "Worker", configurable: true });
|
|
ctx.lastLeftTapTime = Date.now();
|
|
const controller = new InputController(ctx);
|
|
|
|
controller.setupKeyHandlers();
|
|
editor.onLeftAtStart?.();
|
|
|
|
expect(ctx.unfocusSession).toHaveBeenCalledTimes(1);
|
|
expect(ctx.focusParentSession).not.toHaveBeenCalled();
|
|
});
|
|
});
|