diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5d6e890f1..f6149f7ad 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -166,6 +166,7 @@ - Fixed Mnemopi consolidation re-storing cumulative session transcripts after incremental auto-retain, including after resuming a session ([#6058](https://github.com/can1357/oh-my-pi/issues/6058)). - Fixed turn-ending Codex rate-limit errors remaining hidden behind the fullscreen Plan Review overlay and leaving its approval promise pending ([#6086](https://github.com/can1357/oh-my-pi/issues/6086)). - Fixed prewalked subagents continuing to display their starting model after switching to the target model. ([#6083](https://github.com/can1357/oh-my-pi/issues/6083)) +- Fixed Esc aborting an ongoing agent turn instead of overlapping TTS playback, leaving speech uninterruptible ([#6118](https://github.com/can1357/oh-my-pi/issues/6118)). ## [17.0.5] - 2026-07-18 diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index badf66aaa..905ce3f33 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -308,6 +308,14 @@ export class InputController { if (aborted) return; } + if (vocalizer.isSpeaking()) { + // Playback from the completed response can overlap the next agent + // turn. Silence it before interrupting any ongoing main-turn work. + vocalizer.clear(); + this.ctx.lastEscapeTime = 0; + return; + } + if (this.ctx.loopModeEnabled) { this.ctx.pauseLoop(); if (this.ctx.session.isStreaming) { @@ -360,13 +368,6 @@ export class InputController { } else if (this.ctx.editor.getText().trim()) { // Esc must not destroy an in-progress draft. this.ctx.lastEscapeTime = 0; - } 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 - // the double-Esc gesture while Kokoro reads on. Silence first; - // tree/branch stays reachable via a second Esc. - vocalizer.clear(); - this.ctx.lastEscapeTime = 0; } else { // Double-interrupt with empty editor triggers /tree, /branch, or nothing based on setting const action = settings.get("doubleEscapeAction"); diff --git a/packages/coding-agent/test/input-controller-escape.test.ts b/packages/coding-agent/test/input-controller-escape.test.ts index c5fe89ce8..a5a06dea1 100644 --- a/packages/coding-agent/test/input-controller-escape.test.ts +++ b/packages/coding-agent/test/input-controller-escape.test.ts @@ -669,6 +669,24 @@ describe("InputController escape behavior", () => { expect(ctx.showUserMessageSelector).not.toHaveBeenCalled(); }); + it("silences TTS before aborting an overlapping agent turn (#6118)", () => { + const clear = vi.spyOn(vocalizer, "clear").mockImplementation(() => {}); + vi.spyOn(vocalizer, "isSpeaking").mockReturnValue(true); + const { ctx, editor, spies } = createContext(); + const pauseLoop = vi.fn(); + ctx.loopModeEnabled = true; + ctx.pauseLoop = pauseLoop; + mutableSessionState(ctx).isStreaming = true; + const controller = new InputController(ctx); + + controller.setupKeyHandlers(); + editor.onEscape?.(); + + expect(clear).toHaveBeenCalledTimes(1); + expect(pauseLoop).not.toHaveBeenCalled(); + expect(spies.abort).not.toHaveBeenCalled(); + }); + it("silences a still-audible vocalizer on Esc instead of opening the tree selector (#4521)", () => { const clear = vi.spyOn(vocalizer, "clear").mockImplementation(() => {}); const isSpeaking = vi.spyOn(vocalizer, "isSpeaking").mockReturnValue(true);