diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6fc17684f..370fca837 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -81,6 +81,7 @@ - Fixed Escape waiting for an in-flight `session_stop` extension handler to exhaust its timeout; abort now cancels the active stop pass without reporting a false timeout or applying stale continuation context ([#6489](https://github.com/can1357/oh-my-pi/issues/6489)). - Fixed the agent not resuming after re-answering a past `ask` from the session tree. Committing a new answer via `/tree` branched a fresh sibling `toolResult` and rebuilt context, but nothing ever continued the agent — unlike a live `ask`, whose continuation is intrinsic to the streaming run loop — so the model never consumed the new answer and the session sat idle until a manual prompt. `navigateTree` now reports the commit (`askReanswerCommitted`) and the interactive `/tree` handler resumes the agent via `resumeAfterAskReanswer()` *after* its transcript rebuild, so the resumed turn never renders against the stale pre-rebuild UI. Plain leaf moves and the read-only `reopenAsk` probe stay idle ([#6483](https://github.com/can1357/oh-my-pi/issues/6483)). - Fixed Ctrl+C and fatal shutdown entering an `ExtensionExitError` rejection loop while an extension or hook was still loading ([#6488](https://github.com/can1357/oh-my-pi/issues/6488)). +- Added a selectable voice setting for `/live` realtime sessions ([#6566](https://github.com/can1357/oh-my-pi/issues/6566)). ## [17.1.3] - 2026-07-24 diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 787a9da08..a6921c12f 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -2,6 +2,7 @@ import { THINKING_EFFORTS } from "@oh-my-pi/pi-ai"; import { DEFAULT_SHARE_URL } from "@oh-my-pi/pi-wire"; import { SHAPE_VARIANT_NAMES } from "@oh-my-pi/snapcompact"; import { DEFAULT_RELAY_URL } from "../collab/protocol"; +import { DEFAULT_LIVE_VOICE, LIVE_VOICE_OPTIONS, LIVE_VOICE_VALUES } from "../live/voices"; import { DEFAULT_STT_MODEL_KEY, STT_MODEL_OPTIONS, STT_MODEL_VALUES } from "../stt/models"; import { STT_SUBMIT_TRIGGER_OPTIONS, STT_SUBMIT_TRIGGER_VALUES } from "../stt/submit-trigger"; import { AUTO_THINKING, getConfiguredThinkingLevelMetadata, getThinkingLevelMetadata } from "../thinking"; @@ -4845,6 +4846,18 @@ export const SETTINGS_SCHEMA = { ], }, }, + "live.voice": { + type: "enum", + values: LIVE_VOICE_VALUES, + default: DEFAULT_LIVE_VOICE, + ui: { + tab: "providers", + group: "Services", + label: "Live Voice", + description: "Voice used by Codex-backed realtime voice sessions", + options: LIVE_VOICE_OPTIONS, + }, + }, "providers.tts": { type: "enum", values: ["auto", "local", "xai"] as const, diff --git a/packages/coding-agent/src/live/controller.ts b/packages/coding-agent/src/live/controller.ts index d44568a80..61699bc9a 100644 --- a/packages/coding-agent/src/live/controller.ts +++ b/packages/coding-agent/src/live/controller.ts @@ -17,8 +17,8 @@ import { } from "./protocol"; import { CodexLiveTransport } from "./transport"; import type { LivePhase } from "./visualizer"; +import { DEFAULT_LIVE_VOICE } from "./voices"; -const DEFAULT_VOICE = "sol"; const OUTPUT_ACTIVE_LEVEL = 0.015; const MIN_BARGE_IN_LEVEL = 0.04; const OUTPUT_ECHO_RATIO = 0.65; @@ -120,7 +120,7 @@ export class LiveSessionController { this.#session = options.session; this.#callbacks = options.callbacks; this.#extractAssistantText = options.extractAssistantText; - this.#voice = options.voice?.trim() || DEFAULT_VOICE; + this.#voice = options.voice?.trim() || DEFAULT_LIVE_VOICE; } /** Current realtime call phase. */ diff --git a/packages/coding-agent/src/live/voices.ts b/packages/coding-agent/src/live/voices.ts new file mode 100644 index 000000000..d2e581627 --- /dev/null +++ b/packages/coding-agent/src/live/voices.ts @@ -0,0 +1,18 @@ +/** Voices accepted by Codex-backed realtime sessions and exposed in settings. */ +export const LIVE_VOICE_OPTIONS = [ + { value: "arbor", label: "Arbor" }, + { value: "breeze", label: "Breeze" }, + { value: "cove", label: "Cove" }, + { value: "ember", label: "Ember" }, + { value: "juniper", label: "Juniper" }, + { value: "maple", label: "Maple" }, + { value: "sol", label: "Sol" }, + { value: "spruce", label: "Spruce" }, + { value: "vale", label: "Vale" }, +] as const; + +/** Accepted values for the live voice setting. */ +export const LIVE_VOICE_VALUES = LIVE_VOICE_OPTIONS.map(({ value }) => value); + +/** Voice used when no live voice preference is configured. */ +export const DEFAULT_LIVE_VOICE = "sol"; diff --git a/packages/coding-agent/src/modes/controllers/live-command-controller.ts b/packages/coding-agent/src/modes/controllers/live-command-controller.ts index 859241202..a32620a97 100644 --- a/packages/coding-agent/src/modes/controllers/live-command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/live-command-controller.ts @@ -1,6 +1,6 @@ import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { logger } from "@oh-my-pi/pi-utils"; -import { LiveSessionController, type LiveTranscript } from "../../live/controller"; +import { LiveSessionController, type LiveSessionControllerOptions, type LiveTranscript } from "../../live/controller"; import { LIVE_MODEL } from "../../live/protocol"; import { LiveVisualizer } from "../../live/visualizer"; import { vocalizer } from "../../tts/vocalizer"; @@ -11,6 +11,7 @@ import type { InteractiveModeContext } from "../types"; import { createAssistantMessageComponent } from "../utils/interactive-context-helpers"; const ANIMATION_INTERVAL_MS = 80; +type LiveSessionFactory = (options: LiveSessionControllerOptions) => LiveSessionController; const LIVE_MESSAGE_USAGE: AssistantMessage["usage"] = { input: 0, @@ -27,6 +28,7 @@ function errorFrom(cause: unknown): Error { /** Owns the editor-replacing visualizer and realtime session lifecycle for `/live`. */ export class LiveCommandController { readonly #ctx: InteractiveModeContext; + readonly #createSession: LiveSessionFactory | undefined; #session: LiveSessionController | undefined; #settling: Promise | undefined; @@ -40,8 +42,9 @@ export class LiveCommandController { #assistantTranscriptTurn = 0; #assistantTranscriptStartedAt = 0; - constructor(ctx: InteractiveModeContext) { + constructor(ctx: InteractiveModeContext, createSession?: LiveSessionFactory) { this.#ctx = ctx; + this.#createSession = createSession; } /** Whether a live session is connected, connecting, or closing. */ @@ -100,9 +103,10 @@ export class LiveCommandController { this.#mountVisualizer(visualizer); let session: LiveSessionController; - session = new LiveSessionController({ + const options: LiveSessionControllerOptions = { session: this.#ctx.session, extractAssistantText: message => this.#ctx.extractAssistantText(message), + voice: this.#ctx.settings.get("live.voice"), callbacks: { onPhase: phase => { if (this.#visualizer !== visualizer) return; @@ -128,7 +132,8 @@ export class LiveCommandController { }, onTerminal: error => this.#finish(session, error), }, - }); + }; + session = this.#createSession ? this.#createSession(options) : new LiveSessionController(options); this.#session = session; try { diff --git a/packages/coding-agent/test/modes/controllers/live-command-controller.test.ts b/packages/coding-agent/test/modes/controllers/live-command-controller.test.ts new file mode 100644 index 000000000..0ac00e838 --- /dev/null +++ b/packages/coding-agent/test/modes/controllers/live-command-controller.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { LiveSessionController } from "@oh-my-pi/pi-coding-agent/live/controller"; +import { LiveCommandController } from "@oh-my-pi/pi-coding-agent/modes/controllers/live-command-controller"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; + +function createContext(): InteractiveModeContext { + const editor = { + getUseTerminalCursor: vi.fn(() => true), + setUseTerminalCursor: vi.fn(), + }; + return { + settings: Settings.isolated({ "live.voice": "vale" }), + session: {}, + extractAssistantText: vi.fn(() => ""), + editor, + editorContainer: { clear: vi.fn(), addChild: vi.fn() }, + ui: { + getShowHardwareCursor: vi.fn(() => true), + setShowHardwareCursor: vi.fn(), + setFocus: vi.fn(), + requestRender: vi.fn(), + requestComponentRender: vi.fn(), + }, + showError: vi.fn(), + chatContainer: { children: [] }, + present: vi.fn(), + } as unknown as InteractiveModeContext; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("LiveCommandController", () => { + it("forwards the selected voice across the live-session boundary", async () => { + const ctx = createContext(); + let receivedVoice: string | undefined; + const controller = new LiveCommandController(ctx, options => { + receivedVoice = options.voice; + const session = new LiveSessionController(options); + vi.spyOn(session, "start").mockResolvedValue(); + vi.spyOn(session, "stop").mockResolvedValue(); + return session; + }); + + try { + await controller.handleCommand(); + expect(receivedVoice).toBe("vale"); + } finally { + await controller.stop(); + } + }); +});