fix(tui): canceled streaming prompts on first escape
Esc during an active streaming turn required a second press within 2s (two-step arm from #3493). In the no-input-waiter submit path the turn starts with isStreaming=true but no working loader, so Esc fell into the two-step branch and the agent_start subscription then wiped the arm — repeated presses kept re-arming and never aborted. The loader-up path already aborted on a single press, so the confirmation guarded no coherent state. First Esc now aborts the streaming turn directly. Adopted from PR #4938 (test + input-controller + changelog hunks only; unrelated workflow-notice.md churn dropped). Fixes #4921
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
- Fixed pi extensions calling `ctx.ui.addAutocompleteProvider(...)` crashing at load with `TypeError: ... is not a function` — a failure that, for extensions guarding init in one `try/catch` (e.g. `@ff-labs/pi-fff`), aborted the extension's entire initialization. `ExtensionUIContext` now implements pi's autocomplete-provider API: interactive mode stacks each registered factory on top of the built-in editor provider (re-applied on every slash-command refresh, with throwing or malformed factories skipped), while RPC/ACP/headless contexts accept the factory as a no-op. ([#4919](https://github.com/can1357/oh-my-pi/issues/4919))
|
||||
- Fixed bundled reviewer and plan subagents to inherit their model roles' explicit thinking effort suffixes instead of pinning `high` ([#4761](https://github.com/can1357/oh-my-pi/issues/4761)).
|
||||
- Built-in provider model discovery now refreshes an expired stored OAuth credential before an online refresh needs it, instead of silently skipping the provider. The refresh is scoped to the providers actually being discovered (`refreshProvider` cannot rotate unrelated credentials), fires under `online-if-uncached` only when the model manager will actually fetch, and offline discovery stays peek-only ([#4893](https://github.com/can1357/oh-my-pi/issues/4893)).
|
||||
- Fixed Escape during an active TUI prompt requiring a second press before canceling; the first Escape now aborts the streaming turn immediately. ([#4921](https://github.com/can1357/oh-my-pi/issues/4921))
|
||||
|
||||
## [16.3.12] - 2026-07-08
|
||||
|
||||
|
||||
@@ -154,7 +154,6 @@ const TINY_TITLE_PROGRESS_REVEAL_DELAY_MS = 1_000;
|
||||
// deliberate human double-tap is always tens of milliseconds apart.
|
||||
const LEFT_DOUBLE_TAP_MIN_GAP_MS = 40;
|
||||
const LEFT_DOUBLE_TAP_MAX_GAP_MS = 500;
|
||||
const STREAMING_ESCAPE_CANCEL_WINDOW_MS = 2_000;
|
||||
|
||||
export class InputController {
|
||||
constructor(
|
||||
@@ -179,16 +178,6 @@ export class InputController {
|
||||
// (>= LEFT_DOUBLE_TAP_MAX_GAP_MS) starts a fresh sequence. See
|
||||
// #detectLeftDoubleTap.
|
||||
#leftTapCount = 0;
|
||||
// Streaming turns use a two-step Esc: first press arms this token, second press
|
||||
// within the window aborts the same live assistant turn. The token is a per-turn
|
||||
// sentinel minted lazily on demand and reset on every `agent_start`/`agent_end`
|
||||
// (see setupKeyHandlers), so it survives `message_start`/`message_update`
|
||||
// transitions inside a single turn but cannot leak across turn boundaries.
|
||||
#streamingEscapeTurnSentinel: object | undefined;
|
||||
#streamingEscapeArmedToken: object | undefined;
|
||||
#streamingEscapeArmedUntil = 0;
|
||||
#streamingEscapeTimer: NodeJS.Timeout | undefined;
|
||||
#streamingEscapeSessionSubscribed = false;
|
||||
// Sequential index for `local://attachment-N` references created by large-paste and
|
||||
// pasted-file attachments. Seeded from 0 and bumped past existing attachment files.
|
||||
#attachmentCounter = 0;
|
||||
@@ -238,50 +227,12 @@ export class InputController {
|
||||
const unsubscribe = tinyTitleClient.onProgress(update);
|
||||
}
|
||||
|
||||
#clearStreamingEscapeArm(): void {
|
||||
this.#streamingEscapeArmedToken = undefined;
|
||||
this.#streamingEscapeArmedUntil = 0;
|
||||
if (this.#streamingEscapeTimer) {
|
||||
clearTimeout(this.#streamingEscapeTimer);
|
||||
this.#streamingEscapeTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
#handleStreamingEscape(): void {
|
||||
if (!this.#streamingEscapeTurnSentinel) {
|
||||
this.#streamingEscapeTurnSentinel = {};
|
||||
}
|
||||
const token = this.#streamingEscapeTurnSentinel;
|
||||
const now = Date.now();
|
||||
if (this.#streamingEscapeArmedToken === token && now <= this.#streamingEscapeArmedUntil) {
|
||||
this.#clearStreamingEscapeArm();
|
||||
void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
|
||||
return;
|
||||
}
|
||||
|
||||
this.#clearStreamingEscapeArm();
|
||||
this.#streamingEscapeArmedToken = token;
|
||||
this.#streamingEscapeArmedUntil = now + STREAMING_ESCAPE_CANCEL_WINDOW_MS;
|
||||
this.#streamingEscapeTimer = setTimeout(() => {
|
||||
if (this.#streamingEscapeArmedToken === token && Date.now() >= this.#streamingEscapeArmedUntil) {
|
||||
this.#clearStreamingEscapeArm();
|
||||
}
|
||||
}, STREAMING_ESCAPE_CANCEL_WINDOW_MS);
|
||||
this.#streamingEscapeTimer.unref?.();
|
||||
this.ctx.showStatus("Press Esc again within 2s to cancel streaming.");
|
||||
#abortStreamingTurn(): void {
|
||||
void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
|
||||
}
|
||||
|
||||
setupKeyHandlers(): void {
|
||||
this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
|
||||
if (!this.#streamingEscapeSessionSubscribed && typeof this.ctx.session.subscribe === "function") {
|
||||
this.#streamingEscapeSessionSubscribed = true;
|
||||
this.ctx.session.subscribe(event => {
|
||||
if (event.type === "agent_start" || event.type === "agent_end") {
|
||||
this.#streamingEscapeTurnSentinel = undefined;
|
||||
this.#clearStreamingEscapeArm();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!this.#focusedLeftTapListenerInstalled) {
|
||||
this.#focusedLeftTapListenerInstalled = true;
|
||||
this.ctx.ui.addInputListener(data => {
|
||||
@@ -351,7 +302,7 @@ export class InputController {
|
||||
if (this.ctx.loopModeEnabled) {
|
||||
this.ctx.pauseLoop();
|
||||
if (this.ctx.session.isStreaming) {
|
||||
this.#handleStreamingEscape();
|
||||
this.#abortStreamingTurn();
|
||||
} else {
|
||||
this.ctx.cancelPendingSubmission();
|
||||
}
|
||||
@@ -402,11 +353,10 @@ export class InputController {
|
||||
this.ctx.isPythonMode = false;
|
||||
this.ctx.updateEditorBorderColor();
|
||||
} else if (this.ctx.session.isStreaming) {
|
||||
this.#handleStreamingEscape();
|
||||
this.#abortStreamingTurn();
|
||||
} else if (this.ctx.editor.getText().trim()) {
|
||||
// Esc must not destroy an in-progress draft; it only disarms a previous empty-editor Esc.
|
||||
// Esc must not destroy an in-progress draft.
|
||||
this.ctx.lastEscapeTime = 0;
|
||||
this.#clearStreamingEscapeArm();
|
||||
} else if (vocalizer.isSpeaking()) {
|
||||
// TTS buffers seconds of PCM past the streaming abort, so an Esc
|
||||
// arriving after the model stopped would otherwise fall through to
|
||||
|
||||
@@ -269,6 +269,16 @@ function abortViewSession(ctx: InteractiveModeContext): AbortViewSession {
|
||||
// so property access is explicit.
|
||||
return ctx.viewSession as unknown as AbortViewSession;
|
||||
}
|
||||
|
||||
type MutableSessionState = InteractiveModeContext["session"] & {
|
||||
isStreaming: boolean;
|
||||
};
|
||||
|
||||
function mutableSessionState(ctx: InteractiveModeContext): MutableSessionState {
|
||||
// Test harness installs a mutable fake AgentSession; keep the unchecked cast named
|
||||
// so state mutations are explicit.
|
||||
return ctx.session as MutableSessionState;
|
||||
}
|
||||
beforeEach(async () => {
|
||||
await Settings.init({ inMemory: true });
|
||||
});
|
||||
@@ -438,111 +448,37 @@ describe("InputController escape behavior", () => {
|
||||
expect(spies.abort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires a second Esc within two seconds to abort streaming", () => {
|
||||
const now = vi.spyOn(Date, "now");
|
||||
now.mockReturnValue(1_000);
|
||||
it("aborts an active streaming turn on the first Esc without asking for confirmation", () => {
|
||||
const { ctx, editor, spies } = createContext();
|
||||
(ctx.session as { isStreaming: boolean }).isStreaming = true;
|
||||
mutableSessionState(ctx).isStreaming = true;
|
||||
const controller = new InputController(ctx);
|
||||
|
||||
controller.setupKeyHandlers();
|
||||
editor.onEscape?.();
|
||||
|
||||
expect(spies.abort).toHaveBeenCalledTimes(1);
|
||||
expect(spies.abort).toHaveBeenCalledWith({ reason: USER_INTERRUPT_LABEL });
|
||||
expect(spies.showStatus).not.toHaveBeenCalledWith("Press Esc again within 2s to cancel streaming.");
|
||||
});
|
||||
|
||||
it("aborts the submitted turn on the first Esc once the main session starts streaming", async () => {
|
||||
const { ctx, editor, spies } = createContext();
|
||||
const submission = createSubmission({ text: "fix issue #4921" });
|
||||
spies.startPendingSubmission.mockReturnValue(submission);
|
||||
const controller = new InputController(ctx);
|
||||
|
||||
controller.setupKeyHandlers();
|
||||
controller.setupEditorSubmitHandler();
|
||||
await editor.onSubmit?.("fix issue #4921");
|
||||
mutableSessionState(ctx).isStreaming = true;
|
||||
ctx.loadingAnimation = undefined;
|
||||
|
||||
editor.onEscape?.();
|
||||
|
||||
expect(spies.cancelPendingSubmission).not.toHaveBeenCalled();
|
||||
expect(spies.clearQueue).not.toHaveBeenCalled();
|
||||
expect(spies.abort).not.toHaveBeenCalled();
|
||||
expect(spies.showStatus).toHaveBeenCalledWith("Press Esc again within 2s to cancel streaming.");
|
||||
|
||||
now.mockReturnValue(2_500);
|
||||
editor.onEscape?.();
|
||||
|
||||
expect(spies.abort).toHaveBeenCalledTimes(1);
|
||||
expect(spies.abort).toHaveBeenCalledWith({ reason: USER_INTERRUPT_LABEL });
|
||||
});
|
||||
|
||||
it("expires the streaming Esc arm instead of aborting on a late second press", () => {
|
||||
const now = vi.spyOn(Date, "now");
|
||||
now.mockReturnValue(1_000);
|
||||
const { ctx, editor, spies } = createContext();
|
||||
(ctx.session as { isStreaming: boolean }).isStreaming = true;
|
||||
const controller = new InputController(ctx);
|
||||
|
||||
controller.setupKeyHandlers();
|
||||
editor.onEscape?.();
|
||||
now.mockReturnValue(3_001);
|
||||
editor.onEscape?.();
|
||||
|
||||
expect(spies.abort).not.toHaveBeenCalled();
|
||||
expect(spies.showStatus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("preserves the streaming Esc arm when streamingComponent appears between presses", () => {
|
||||
// Pre-`message_start`: first Esc arms on the per-turn sentinel. `message_start`
|
||||
// then publishes `ctx.streamingComponent`; the second Esc must still abort the
|
||||
// same live turn instead of re-arming on the new component reference.
|
||||
const now = vi.spyOn(Date, "now");
|
||||
now.mockReturnValue(1_000);
|
||||
const { ctx, editor, spies } = createContext();
|
||||
(ctx.session as { isStreaming: boolean }).isStreaming = true;
|
||||
const controller = new InputController(ctx);
|
||||
|
||||
controller.setupKeyHandlers();
|
||||
editor.onEscape?.();
|
||||
(ctx as unknown as { streamingComponent: object }).streamingComponent = {};
|
||||
now.mockReturnValue(1_500);
|
||||
editor.onEscape?.();
|
||||
|
||||
expect(spies.abort).toHaveBeenCalledTimes(1);
|
||||
expect(spies.abort).toHaveBeenCalledWith({ reason: USER_INTERRUPT_LABEL });
|
||||
});
|
||||
|
||||
it("aborts on the second Esc even when ctx.streamingMessage was replaced by a delta in between", () => {
|
||||
// `EventController` replaces `ctx.streamingMessage` with a fresh immutable
|
||||
// snapshot on every `message_update`; the per-turn sentinel is unaffected so
|
||||
// swapping the message must not invalidate the armed token.
|
||||
const now = vi.spyOn(Date, "now");
|
||||
now.mockReturnValue(1_000);
|
||||
const { ctx, editor, spies } = createContext();
|
||||
(ctx.session as { isStreaming: boolean }).isStreaming = true;
|
||||
(ctx as unknown as { streamingComponent: object }).streamingComponent = {};
|
||||
(ctx as unknown as { streamingMessage: object }).streamingMessage = { content: [] };
|
||||
const controller = new InputController(ctx);
|
||||
|
||||
controller.setupKeyHandlers();
|
||||
editor.onEscape?.();
|
||||
(ctx as unknown as { streamingMessage: object }).streamingMessage = { content: ["delta"] };
|
||||
now.mockReturnValue(1_500);
|
||||
editor.onEscape?.();
|
||||
|
||||
expect(spies.abort).toHaveBeenCalledTimes(1);
|
||||
expect(spies.abort).toHaveBeenCalledWith({ reason: USER_INTERRUPT_LABEL });
|
||||
});
|
||||
|
||||
it("clears the streaming Esc arm when the current turn ends", () => {
|
||||
const now = vi.spyOn(Date, "now");
|
||||
now.mockReturnValue(1_000);
|
||||
const { ctx, editor, spies, sessionListeners } = createContext();
|
||||
(ctx.session as { isStreaming: boolean }).isStreaming = true;
|
||||
const controller = new InputController(ctx);
|
||||
|
||||
controller.setupKeyHandlers();
|
||||
// Fallback arm (no streamingMessage/streamingComponent yet — pre-message_start).
|
||||
editor.onEscape?.();
|
||||
expect(sessionListeners).toHaveLength(1);
|
||||
|
||||
// Turn 1 ends; a new turn starts. session.subscribe receives both transitions,
|
||||
// either of which must invalidate the still-armed fallback token so it cannot
|
||||
// fast-abort the new turn's first Esc.
|
||||
for (const listener of sessionListeners) {
|
||||
listener({ type: "agent_end" });
|
||||
listener({ type: "agent_start" });
|
||||
}
|
||||
|
||||
now.mockReturnValue(1_500);
|
||||
editor.onEscape?.();
|
||||
|
||||
expect(spies.abort).not.toHaveBeenCalled();
|
||||
expect(spies.showStatus).toHaveBeenCalledTimes(2);
|
||||
expect(spies.showStatus).not.toHaveBeenCalledWith("Press Esc again within 2s to cancel streaming.");
|
||||
});
|
||||
|
||||
it("returns focused subagent view to main on Esc instead of aborting", () => {
|
||||
|
||||
Reference in New Issue
Block a user