diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9c2395e3d..7a1b652a1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Esc no longer stopping TTS audio playback once the assistant reply had finished streaming; queued Kokoro (and remote) audio kept reading past the response's end because `vocalizer.clear()` only fired from the aborted-stream cascade. Esc now silences a still-audible vocalizer as its first action (ahead of the `tree`/`branch` double-Esc gesture); a second Esc keeps its previous behavior. ([#4521](https://github.com/can1357/oh-my-pi/issues/4521)) + ## [16.3.6] - 2026-07-04 ### Changed diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 551c29d31..ed16eb36c 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -23,6 +23,7 @@ import { isLowSignalTitleInput } from "../../tiny/text"; import { tinyTitleClient } from "../../tiny/title-client"; import type { TinyTitleProgressEvent } from "../../tiny/title-protocol"; import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils"; +import { vocalizer } from "../../tts/vocalizer"; import { copyToClipboard, readImageFromClipboard, @@ -390,6 +391,13 @@ export class InputController { // Esc must not destroy an in-progress draft; it only disarms a previous empty-editor Esc. 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 + // 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/src/tts/vocalizer.ts b/packages/coding-agent/src/tts/vocalizer.ts index 38a73a020..bb567a099 100644 --- a/packages/coding-agent/src/tts/vocalizer.ts +++ b/packages/coding-agent/src/tts/vocalizer.ts @@ -195,6 +195,15 @@ export class Vocalizer { this.#livePlayers.clear(); } + /** + * True while any utterance is still audible or synthesizing — a live + * player, an unfinished stream handle, or an in-flight rewrite is enough. + * Callers (Esc handler) treat this as the "silence me" signal. + */ + isSpeaking(): boolean { + return this.#livePlayers.size > 0 || this.#liveAborts.size > 0 || this.#handle !== null; + } + /** Lower the volume while the user is speaking (push-to-talk), so speech doesn't drown them out. */ duck(): void { this.#ducked = true; diff --git a/packages/coding-agent/test/input-controller-escape.test.ts b/packages/coding-agent/test/input-controller-escape.test.ts index e78be23ba..e7ae3af47 100644 --- a/packages/coding-agent/test/input-controller-escape.test.ts +++ b/packages/coding-agent/test/input-controller-escape.test.ts @@ -4,6 +4,7 @@ import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config 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"; +import { vocalizer } from "@oh-my-pi/pi-coding-agent/tts/vocalizer"; import * as logger from "@oh-my-pi/pi-utils/logger"; type Spy = Mock<(...args: unknown[]) => unknown>; @@ -702,6 +703,27 @@ describe("InputController escape behavior", () => { expect(ctx.showTreeSelector).not.toHaveBeenCalled(); expect(ctx.showUserMessageSelector).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); + const { ctx, editor, spies } = createContext(); + const controller = new InputController(ctx); + + controller.setupKeyHandlers(); + editor.onEscape?.(); + + expect(clear).toHaveBeenCalledTimes(1); + expect(ctx.showTreeSelector).not.toHaveBeenCalled(); + expect(ctx.showUserMessageSelector).not.toHaveBeenCalled(); + expect(spies.resetDisplay).not.toHaveBeenCalled(); + + // A second Esc after silence must NOT immediately fire the double-Esc + // gesture — the first press consumed the arm. + isSpeaking.mockReturnValue(false); + editor.onEscape?.(); + expect(ctx.showTreeSelector).not.toHaveBeenCalled(); + }); }); describe("InputController Ctrl+C behavior", () => {