diff --git a/bun.lock b/bun.lock index 036fbbe97a..60b09a3ee9 100644 --- a/bun.lock +++ b/bun.lock @@ -399,6 +399,7 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/ui": "workspace:*", "@openrouter/ai-sdk-provider": "2.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e9b811fc5e..4f7c31ad7f 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -108,6 +108,7 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/ui": "workspace:*", "@openrouter/ai-sdk-provider": "2.8.1", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index 4311e05fce..0c4e22fc74 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -34,7 +34,11 @@ Example: "enabled": true, "notifications": true, "sound": true, - "volume": 0.4 + "volume": 0.4, + "sound_pack": "opencode.default", + "sounds": { + "error": "/Users/me/sounds/error.mp3" + } } } ``` @@ -54,6 +58,8 @@ Example: - `attention.enabled` disables all `api.attention.notify(...)` delivery when set to `false`. - `attention.notifications` and `attention.sound` independently control terminal-mediated desktop notifications and built-in sounds. - `attention.volume` sets the default built-in sound volume from `0` to `1`. +- `attention.sound_pack` selects the initial semantic sound pack. Persisted runtime selection in KV can override it. +- `attention.sounds` overrides individual semantic sound slots such as `error` or `done`. - `leader_timeout` is a top-level TUI setting. - `keybinds` is a flat object keyed by command id; values are key binding values (`false`, `"none"`, a key string/object, a binding object, or an array of key strings/objects/binding objects). - `keybinds.leader` sets the key used by `` shortcuts. @@ -262,8 +268,14 @@ Top-level API groups exposed to `tui(api, options, meta)`: - `message` is required; `title` defaults to `"opencode"`; `when` defaults to `"always"`; `sound` defaults to `false`. - `when: "always"` requests delivery regardless of terminal focus state. - `when: "focused"` only requests delivery after the terminal is known focused; `when: "blurred"` only requests delivery after the terminal is known blurred. +- Semantic sound names are `"default"`, `"question"`, `"permission"`, `"error"`, and `"done"`. +- `sound: true` plays the `"default"` sound; `sound: "question"` or `sound: { name: "question" }` plays a named semantic sound. +- `sound: { volume }` overrides volume for that call; `sound: { enabled: false }` disables sound for that call. +- `api.attention.soundboard.registerPack({ id, name?, sounds })` registers a sound pack and returns a disposer. Relative paths resolve from the plugin root and are cleaned up on plugin deactivation. +- `api.attention.soundboard.activate(id, { persist })` selects the active pack. `persist: true` writes the selected pack id to TUI KV state, not `tui.json`. +- `api.attention.soundboard.current()` and `list()` expose the active/registered packs for plugin UX. +- Config `attention.sounds` overrides active-pack sounds by slot. Failed loads fall back to the active pack and then `opencode.default`. - The host strips ANSI/control characters and collapses newlines before sending text to the terminal notification API. -- `sound: true` plays the built-in attention sound at `attention.volume`; `sound: { volume }` overrides it for that call; `sound: { enabled: false }` disables sound for that call. - Terminal and OS settings decide whether a requested notification is visibly displayed. - Prefer privacy-safe messages such as `"A question needs your input"`; avoid full commands, paths, prompts, errors, secrets, or file contents unless the plugin intentionally exposes them. diff --git a/packages/opencode/src/audio.d.ts b/packages/opencode/src/audio.d.ts index c7c947450d..7b99d097a3 100644 --- a/packages/opencode/src/audio.d.ts +++ b/packages/opencode/src/audio.d.ts @@ -3,6 +3,11 @@ declare module "*.wav" { export default file } +declare module "*.mp3" { + const file: string + export default file +} + declare module "*.wasm" { const file: string export default file diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 4d24d0c2ad..ac8f4d5dae 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -63,7 +63,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import { createTuiApi } from "@/cli/cmd/tui/plugin/api" import type { RouteMap } from "@/cli/cmd/tui/plugin/api" -import { createTuiAttention, type TuiAttentionHost } from "@/cli/cmd/tui/attention" +import { createTuiAttention } from "@/cli/cmd/tui/attention" import { FormatError, FormatUnknownError } from "@/cli/error" import { CommandPaletteProvider, useCommandPalette } from "./context/command-palette" import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencodeKeymap } from "./keymap" @@ -177,15 +177,12 @@ export function tui(input: { unguard?.() resolve() } - const onBeforeExit = async () => { offKeymap() await TuiPluginRuntime.dispose() - attention.dispose() } const renderer = await createCliRenderer(rendererConfig(input.config)) - const attention = createTuiAttention({ renderer, config: input.config }) // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash. void renderer.getPalette({ size: 16 }).catch(() => undefined) const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" @@ -235,7 +232,7 @@ export function tui(input: { - + @@ -262,7 +259,7 @@ export function tui(input: { }) } -function App(props: { onSnapshot?: () => Promise; attention: TuiAttentionHost }) { +function App(props: { onSnapshot?: () => Promise }) { const tuiConfig = useTuiConfig() const route = useRoute() const dimensions = useTerminalDimensions() @@ -286,6 +283,7 @@ function App(props: { onSnapshot?: () => Promise; attention: TuiAttent routeRev() return routes.get(name)?.at(-1)?.render } + const attention = createTuiAttention({ renderer, config: tuiConfig, kv }) const api = createTuiApi({ tuiConfig, @@ -301,12 +299,13 @@ function App(props: { onSnapshot?: () => Promise; attention: TuiAttent theme: themeState, toast, renderer, - attention: props.attention, + attention, }) const [ready, setReady] = createSignal(false) TuiPluginRuntime.init({ api, config: tuiConfig, + attention, }) .catch((error) => { console.error("Failed to load TUI plugins", error) diff --git a/packages/opencode/src/cli/cmd/tui/attention.ts b/packages/opencode/src/cli/cmd/tui/attention.ts index 623e44306c..9f10694fda 100644 --- a/packages/opencode/src/cli/cmd/tui/attention.ts +++ b/packages/opencode/src/cli/cmd/tui/attention.ts @@ -4,10 +4,18 @@ import type { TuiAttentionNotifyInput, TuiAttentionNotifyResult, TuiAttentionNotifySkipReason, + TuiKV, + TuiAttentionSoundName, + TuiAttentionSoundPack, + TuiAttentionSoundPackInfo, } from "@opencode-ai/plugin/tui" import stripAnsi from "strip-ansi" import type { TuiConfig } from "./config/tui" -import attentionSoundPath from "./asset/pulse-a.wav" with { type: "file" } +import defaultSoundPath from "@opencode-ai/ui/audio/alert-01.mp3" with { type: "file" } +import questionSoundPath from "@opencode-ai/ui/audio/alert-02.mp3" with { type: "file" } +import permissionSoundPath from "@opencode-ai/ui/audio/alert-03.mp3" with { type: "file" } +import errorSoundPath from "@opencode-ai/ui/audio/nope-01.mp3" with { type: "file" } +import doneSoundPath from "@opencode-ai/ui/audio/yup-01.mp3" with { type: "file" } import * as Log from "@opencode-ai/core/util/log" type FocusState = "unknown" | "focused" | "blurred" @@ -30,7 +38,11 @@ type AttentionAudioEngine = { type AttentionAudio = { create(): AttentionAudioEngine - bytes(): Promise + bytes(path: string): Promise +} + +type RegisteredSoundPack = TuiAttentionSoundPack & { + builtin: boolean } export type TuiAttentionHost = TuiAttention & { @@ -40,8 +52,23 @@ export type TuiAttentionHost = TuiAttention & { const log = Log.create({ service: "tui.attention" }) const DEFAULT_TITLE = "opencode" +const DEFAULT_PACK_ID = "opencode.default" +const KV_SOUND_PACK = "attention_sound_pack" const TITLE_LIMIT = 80 const MESSAGE_LIMIT = 240 +const SOUND_NAMES = ["default", "question", "permission", "error", "done"] as const satisfies readonly TuiAttentionSoundName[] +const BUILTIN_PACK: RegisteredSoundPack = { + id: DEFAULT_PACK_ID, + name: "OpenCode Default", + builtin: true, + sounds: { + default: defaultSoundPath, + question: questionSoundPath, + permission: permissionSoundPath, + error: errorSoundPath, + done: doneSoundPath, + }, +} function skipped(reason: TuiAttentionNotifySkipReason): TuiAttentionNotifyResult { return { @@ -70,10 +97,36 @@ function soundVolume(input: TuiAttentionNotifyInput, config: Pick + isSoundName(item[0]) && typeof item[1] === "string" && item[1].trim().length > 0, + ), + ), + } +} + function focusSkip(when: TuiAttentionNotifyInput["when"], focus: FocusState) { if ((when ?? "always") === "always") return if (focus === "unknown") return "focus_unknown" @@ -84,13 +137,15 @@ function focusSkip(when: TuiAttentionNotifyInput["when"], focus: FocusState) { export function createTuiAttention(input: { renderer: AttentionRenderer config: Pick + kv?: TuiKV audio?: AttentionAudio }): TuiAttentionHost { let focus: FocusState = "unknown" let disposed = false let audio: AttentionAudioEngine | undefined - let sound: AudioSound | null | undefined - let soundTask: Promise | undefined + let activePackID: string | undefined + const packs = new Map([[BUILTIN_PACK.id, BUILTIN_PACK]]) + const sounds = new Map>() const audioInput = input.audio ?? @@ -102,7 +157,7 @@ export function createTuiAttention(input: { }) return engine }, - bytes: () => Bun.file(attentionSoundPath).bytes(), + bytes: (file) => Bun.file(file).bytes(), } satisfies AttentionAudio) const onFocus = () => { @@ -115,27 +170,46 @@ export function createTuiAttention(input: { input.renderer.on("focus", onFocus) input.renderer.on("blur", onBlur) - async function loadSound() { - if (!audio) return null - if (sound !== undefined) return sound - soundTask ??= audioInput - .bytes() - .then((bytes) => audio?.loadSound(bytes) ?? null) - .catch((error) => { - log.debug("failed to load attention sound", { error }) - return null - }) - sound = await soundTask - return sound + function configuredPackID() { + const stored = input.kv?.get(KV_SOUND_PACK, undefined) + return activePackID ?? stored ?? input.config.attention.sound_pack } - async function playSound(volume: number) { + function currentPack() { + return packs.get(configuredPackID()) ?? BUILTIN_PACK + } + + function soundCandidates(name: TuiAttentionSoundName) { + return [input.config.attention.sounds[name], currentPack().sounds[name], BUILTIN_PACK.sounds[name]].filter( + (item, index, list): item is string => typeof item === "string" && list.indexOf(item) === index, + ) + } + + async function loadSound(file: string) { + if (!audio) return null + const cached = sounds.get(file) + if (cached) return cached + const task = audioInput + .bytes(file) + .then((bytes) => audio?.loadSound(bytes) ?? null) + .catch((error) => { + log.debug("failed to load attention sound", { file, error }) + return null + }) + sounds.set(file, task) + return task + } + + async function playSound(name: TuiAttentionSoundName, volume: number) { try { audio ??= audioInput.create() if (!audio.isStarted() && !audio.start()) return false - const current = await loadSound() - if (current == null) return false - return audio.play(current, { volume }) != null + for (const file of soundCandidates(name)) { + const current = await loadSound(file) + if (current == null) continue + if (audio.play(current, { volume }) != null) return true + } + return false } catch (error) { log.debug("failed to play attention sound", { error }) return false @@ -165,7 +239,7 @@ export function createTuiAttention(input: { })() : false const volume = soundVolume(request, input.config) - const sound = volume === undefined ? false : await playSound(volume) + const sound = volume === undefined ? false : await playSound(soundName(request), volume) return { ok: notification || sound, @@ -181,14 +255,45 @@ export function createTuiAttention(input: { } } }, + soundboard: { + registerPack(pack) { + const next = normalizePack(pack) + if (!next) return () => {} + packs.set(next.id, next) + let disposed = false + return () => { + if (disposed) return + disposed = true + if (packs.get(next.id) === next) packs.delete(next.id) + } + }, + activate(id, options) { + const pack = packs.get(id) + if (!pack) return false + activePackID = pack.id + if (options?.persist) input.kv?.set(KV_SOUND_PACK, pack.id) + return true + }, + current() { + return currentPack().id + }, + list(): TuiAttentionSoundPackInfo[] { + const current = currentPack().id + return Array.from(packs.values()).map((pack) => ({ + id: pack.id, + name: pack.name, + active: pack.id === current, + builtin: pack.builtin, + })) + }, + }, dispose() { disposed = true input.renderer.off("focus", onFocus) input.renderer.off("blur", onBlur) audio?.dispose() audio = undefined - sound = undefined - soundTask = undefined + sounds.clear() }, } } diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 719ae07964..a4f4101054 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -7,6 +7,17 @@ const KeymapLeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({ description: "Leader key timeout in milliseconds", }) +export const TuiAttentionSoundNames = ["default", "question", "permission", "error", "done"] as const +export type TuiAttentionSoundName = (typeof TuiAttentionSoundNames)[number] + +const TuiAttentionSounds = Schema.Struct({ + default: Schema.optional(Schema.String), + question: Schema.optional(Schema.String), + permission: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), + done: Schema.optional(Schema.String), +}) + export const ScrollSpeed = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001)) export const ScrollAcceleration = Schema.Struct({ @@ -22,6 +33,8 @@ export const Attention = Schema.Struct({ notifications: Schema.optional(Schema.Boolean), sound: Schema.optional(Schema.Boolean), volume: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1))), + sound_pack: Schema.optional(Schema.String), + sounds: Schema.optional(TuiAttentionSounds), }).annotate({ description: "Attention notification and sound settings" }) export const TuiInfo = Schema.Struct({ diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 955f9b21c7..2b1248c18a 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -1,5 +1,7 @@ export * as TuiConfig from "./tui" +import { fileURLToPath } from "url" +import path from "path" import { createBindingLookup } from "@opentui/keymap/extras" import { mergeDeep, unique } from "remeda" import { Context, Effect, Fiber, Layer, Schema } from "effect" @@ -7,7 +9,7 @@ import { ConfigParse } from "@/config/parse" import { InvalidError } from "@/config/error" import * as ConfigPaths from "@/config/paths" import { migrateTuiConfig } from "./tui-migrate" -import { KeymapLeaderTimeoutDefault, TuiInfo } from "./tui-schema" +import { KeymapLeaderTimeoutDefault, TuiAttentionSoundNames, TuiInfo } from "./tui-schema" import { Flag } from "@opencode-ai/core/flag/flag" import { isRecord } from "@/util/record" import { Global } from "@opencode-ai/core/global" @@ -39,6 +41,8 @@ export type Resolved = Omit & notifications: boolean sound: boolean volume: number + sound_pack: string + sounds: Partial> } keybinds: TuiKeybind.BindingLookupView leader_timeout: number @@ -75,6 +79,29 @@ function normalize(raw: Record) { } } +function resolveSoundPath(value: string, configFilepath: string) { + const raw = value.startsWith("file://") ? fileURLToPath(value) : value + if (path.isAbsolute(raw)) return raw + return path.resolve(path.dirname(configFilepath), raw) +} + +function resolveAttentionSounds(config: Info, configFilepath: string): Info { + if (!config.attention?.sounds) return config + return { + ...config, + attention: { + ...config.attention, + sounds: Object.fromEntries( + TuiAttentionSoundNames.flatMap((name) => { + const value = config.attention?.sounds?.[name] + if (!value) return [] + return [[name, resolveSoundPath(value, configFilepath)]] + }), + ), + }, + } +} + const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) { const afs = yield* AppFileSystem.Service @@ -107,7 +134,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: }) } } - const validated = ConfigParse.schema(Info, normalized, configFilepath) + const validated = resolveAttentionSounds(ConfigParse.schema(Info, normalized, configFilepath), configFilepath) return yield* resolvePlugins(validated, configFilepath) }).pipe( // catchCause (not tapErrorCause + orElseSucceed) because JSONC parsing and validation @@ -208,6 +235,8 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: notifications: acc.result.attention?.notifications ?? true, sound: acc.result.attention?.sound ?? true, volume: acc.result.attention?.volume ?? 0.4, + sound_pack: acc.result.attention?.sound_pack ?? "opencode.default", + sounds: acc.result.attention?.sounds ?? {}, }, keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), { commandMap: TuiKeybind.CommandMap, diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 901ee36ff8..af89b4e00c 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -37,6 +37,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal" import { setupSlots, Slot as View } from "./slots" import type { HostPluginApi, HostSlots } from "./slots" +import type { TuiAttentionHost } from "../attention" import { ConfigPlugin } from "@/config/plugin" import { createCommandShim } from "./command-shim" @@ -106,6 +107,7 @@ const ScopedKeymapMethods = new Set([ type RuntimeState = { directory: string api: Api + attention?: TuiAttentionHost slots: HostSlots plugins: PluginEntry[] plugins_by_id: Map @@ -156,6 +158,39 @@ function createScopedKeymap(keymap: TuiPluginApi["keymap"], scope: PluginScope): }) } +function createScopedAttention( + attention: TuiPluginApi["attention"], + scope: PluginScope, + root: string, +): TuiPluginApi["attention"] { + return { + notify(input) { + return attention.notify(input) + }, + soundboard: { + registerPack(pack) { + return scope.track( + attention.soundboard.registerPack({ + ...pack, + sounds: Object.fromEntries( + Object.entries(pack.sounds).map(([name, file]) => [name, resolvePluginFile(root, file)]), + ), + }), + ) + }, + activate(id, options) { + return attention.soundboard.activate(id, options) + }, + current() { + return attention.soundboard.current() + }, + list() { + return attention.soundboard.list() + }, + }, + } +} + type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" } function runCleanup(fn: () => unknown, ms: number): Promise { @@ -197,6 +232,12 @@ function resolveRoot(root: string) { return path.resolve(process.cwd(), root) } +function resolvePluginFile(root: string, file: string) { + const raw = file.startsWith("file://") ? fileURLToPath(file) : file + if (path.isAbsolute(raw)) return raw + return path.resolve(root, raw) +} + function createThemeInstaller( meta: ConfigPlugin.Origin, root: string, @@ -576,7 +617,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop return { app: api.app, - attention: api.attention, + attention: createScopedAttention(api.attention, scope, load.theme_root), // Keep deprecated `api.command` working for v1 plugins; remove in v2. command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds), keys: api.keys, @@ -968,7 +1009,7 @@ let loaded: Promise | undefined let runtime: RuntimeState | undefined export const Slot = View -export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved }) { +export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved; attention?: TuiAttentionHost }) { const cwd = process.cwd() if (loaded) { if (dir !== cwd) { @@ -1015,15 +1056,17 @@ export async function dispose() { for (const plugin of queue) { await deactivatePluginEntry(state, plugin, false) } + state.attention?.dispose() } -async function load(input: { api: Api; config: TuiConfig.Resolved }) { +async function load(input: { api: Api; config: TuiConfig.Resolved; attention?: TuiAttentionHost }) { const { api, config } = input const cwd = process.cwd() const slots = setupSlots(api) const next: RuntimeState = { directory: cwd, api, + attention: input.attention, slots, plugins: [], plugins_by_id: new Map(), diff --git a/packages/opencode/test/cli/cmd/tui/attention.test.ts b/packages/opencode/test/cli/cmd/tui/attention.test.ts index 0d7b3a127f..a97a21187e 100644 --- a/packages/opencode/test/cli/cmd/tui/attention.test.ts +++ b/packages/opencode/test/cli/cmd/tui/attention.test.ts @@ -95,20 +95,39 @@ class FakeAudio { engine = new FakeAudioEngine() createCalls = 0 bytesCalls = 0 + bytesPaths: string[] = [] rejectBytes = false + rejectPaths = new Set() create() { this.createCalls += 1 return this.engine } - async bytes() { + async bytes(path: string) { this.bytesCalls += 1 - if (this.rejectBytes) throw new Error("decode failed") + this.bytesPaths.push(path) + if (this.rejectBytes || this.rejectPaths.has(path)) throw new Error("decode failed") return new Uint8Array([1, 2, 3]) } } +class FakeKV { + store: Record = {} + + get ready() { + return true + } + + get(key: string, fallback?: Value) { + return (this.store[key] ?? fallback) as Value + } + + set(key: string, value: unknown) { + this.store[key] = value + } +} + function config(attention: Partial = {}): AttentionConfig { return { attention: { @@ -116,6 +135,8 @@ function config(attention: Partial = {}): Attentio notifications: true, sound: true, volume: 0.4, + sound_pack: "opencode.default", + sounds: {}, ...attention, }, } @@ -335,6 +356,84 @@ describe("createTuiAttention", () => { expect(startedAudio.engine.playCalls).toBe(2) }) + test("plays named sounds from the active sound pack", async () => { + const renderer = new FakeRenderer() + const audio = new FakeAudio() + const attention = createTuiAttention({ renderer, config: config(), audio }) + renderer.emit("blur") + + const dispose = attention.soundboard.registerPack({ + id: "acme.soft", + name: "Soft Alerts", + sounds: { + question: "/tmp/question.mp3", + }, + }) + + expect(attention.soundboard.activate("acme.soft")).toBe(true) + expect(attention.soundboard.current()).toBe("acme.soft") + expect(attention.soundboard.list()).toContainEqual({ + id: "acme.soft", + name: "Soft Alerts", + active: true, + builtin: false, + }) + + expect(await attention.notify({ message: "question", sound: { name: "question" } })).toEqual({ + ok: true, + notification: true, + sound: true, + }) + expect(audio.bytesPaths).toEqual(["/tmp/question.mp3"]) + + dispose() + expect(attention.soundboard.current()).toBe("opencode.default") + }) + + test("uses config sound overrides before active pack sounds and falls back on load failure", async () => { + const renderer = new FakeRenderer() + const audio = new FakeAudio() + audio.rejectPaths.add("/tmp/bad-question.mp3") + const attention = createTuiAttention({ + renderer, + config: config({ sounds: { question: "/tmp/bad-question.mp3" } }), + audio, + }) + renderer.emit("blur") + + attention.soundboard.registerPack({ + id: "acme.soft", + sounds: { + question: "/tmp/good-question.mp3", + }, + }) + attention.soundboard.activate("acme.soft") + + expect(await attention.notify({ message: "question", sound: "question" })).toEqual({ + ok: true, + notification: true, + sound: true, + }) + expect(audio.bytesPaths).toEqual(["/tmp/bad-question.mp3", "/tmp/good-question.mp3"]) + }) + + test("persists activated sound pack in KV", () => { + const kv = new FakeKV() + const renderer = new FakeRenderer() + const attention = createTuiAttention({ renderer, config: config(), kv }) + + attention.soundboard.registerPack({ id: "acme.soft", sounds: { done: "/tmp/done.mp3" } }) + + expect(attention.soundboard.activate("missing", { persist: true })).toBe(false) + expect(kv.store.attention_sound_pack).toBeUndefined() + expect(attention.soundboard.activate("acme.soft", { persist: true })).toBe(true) + expect(kv.store.attention_sound_pack).toBe("acme.soft") + + const next = createTuiAttention({ renderer: new FakeRenderer(), config: config(), kv }) + next.soundboard.registerPack({ id: "acme.soft", sounds: { done: "/tmp/done.mp3" } }) + expect(next.soundboard.current()).toBe("acme.soft") + }) + test("does not throw for notification or sound failures", async () => { const renderer = new FakeRenderer() const audio = new FakeAudio() diff --git a/packages/opencode/test/cli/run/runtime.boot.test.ts b/packages/opencode/test/cli/run/runtime.boot.test.ts index 3d796c564d..43499eea0a 100644 --- a/packages/opencode/test/cli/run/runtime.boot.test.ts +++ b/packages/opencode/test/cli/run/runtime.boot.test.ts @@ -98,6 +98,8 @@ function config(input?: { notifications: true, sound: true, volume: 0.4, + sound_pack: "opencode.default", + sounds: {}, }, diff_style: input?.diff_style, keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), { diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 493520fc00..27499a4993 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -854,6 +854,75 @@ test("plugin keymap proxy preserves real keymap receiver", async () => { } }) +test("auto-disposes plugin attention sound packs and resolves relative paths", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const file = path.join(dir, "attention-soundpack-plugin.ts") + const spec = pathToFileURL(file).href + + await Bun.write( + file, + `export default { + id: "demo.attention.soundpack", + tui: async (api) => { + api.attention.soundboard.registerPack({ + id: "demo.pack", + sounds: { question: "sounds/question.mp3" }, + }) + }, +} +`, + ) + + return { spec } + }, + }) + + const packs: Array<{ id: string; sounds: Record }> = [] + let dropped = 0 + const attention = { + async notify() { + return { ok: false, notification: false, sound: false } + }, + soundboard: { + registerPack(pack: { id: string; sounds: Record }) { + packs.push(pack) + return () => { + dropped += 1 + } + }, + activate: () => false, + current: () => "opencode.default", + list: () => [], + }, + } as NonNullable[0]>["attention"] + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + + try { + await TuiPluginRuntime.init({ + api: createTuiPluginApi({ attention }), + config: createTuiResolvedConfig({ + plugin: [tmp.extra.spec], + plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }], + }), + }) + + expect(packs).toEqual([ + { + id: "demo.pack", + sounds: { question: path.join(tmp.path, "sounds", "question.mp3") }, + }, + ]) + expect(dropped).toBe(0) + } finally { + await TuiPluginRuntime.dispose() + expect(dropped).toBe(1) + cwd.mockRestore() + wait.mockRestore() + } +}) + test("auto-disposes plugin keymap transformers", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index c6c9044709..5b98bceae4 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -149,13 +149,28 @@ test("resolves attention config defaults and overrides", async () => { notifications: true, sound: true, volume: 0.4, + sound_pack: "opencode.default", + sounds: {}, }) await using overridden = await tmpdir({ init: async (dir) => { await Bun.write( path.join(dir, "tui.json"), - JSON.stringify({ attention: { enabled: false, notifications: false, sound: false, volume: 0.7 } }, null, 2), + JSON.stringify( + { + attention: { + enabled: false, + notifications: false, + sound: false, + volume: 0.7, + sound_pack: "acme.soft", + sounds: { error: "./error.mp3" }, + }, + }, + null, + 2, + ), ) }, }) @@ -165,6 +180,8 @@ test("resolves attention config defaults and overrides", async () => { notifications: false, sound: false, volume: 0.7, + sound_pack: "acme.soft", + sounds: { error: path.join(overridden.path, "error.mp3") }, }) }) diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 2994773072..208f94fc8b 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -194,6 +194,12 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { sound: false, } }, + soundboard: { + registerPack: () => () => {}, + activate: () => false, + current: () => "opencode.default", + list: () => [], + }, }, keys: { formatSequence: () => "", diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts index 3f928ef8c0..338cf930b8 100644 --- a/packages/opencode/test/fixture/tui-runtime.ts +++ b/packages/opencode/test/fixture/tui-runtime.ts @@ -28,6 +28,8 @@ export function createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Re notifications: true, sound: true, volume: 0.4, + sound_pack: "opencode.default", + sounds: {}, ...input.attention, }, keybinds: createTuiResolvedKeybinds(keybinds), diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index b057adf890..9719f2a640 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -227,13 +227,41 @@ export type TuiToast = { export type TuiAttentionWhen = "always" | "focused" | "blurred" +export type TuiAttentionSoundName = "default" | "question" | "permission" | "error" | "done" + export type TuiAttentionSound = | boolean + | TuiAttentionSoundName | { enabled?: boolean + name?: TuiAttentionSoundName volume?: number } +export type TuiAttentionSoundPack = { + id: string + name?: string + sounds: Partial> +} + +export type TuiAttentionSoundPackInfo = { + id: string + name?: string + active: boolean + builtin: boolean +} + +export type TuiAttentionSoundboardActivateOptions = { + persist?: boolean +} + +export type TuiAttentionSoundboard = { + registerPack(pack: TuiAttentionSoundPack): () => void + activate(id: string, options?: TuiAttentionSoundboardActivateOptions): boolean + current(): string + list(): ReadonlyArray +} + export type TuiAttentionNotifyInput = { title?: string message: string @@ -258,6 +286,7 @@ export type TuiAttentionNotifyResult = { export type TuiAttention = { notify(input: TuiAttentionNotifyInput): Promise + soundboard: TuiAttentionSoundboard } export type TuiThemeCurrent = { @@ -371,6 +400,8 @@ type TuiAttentionConfigView = { notifications: boolean sound: boolean volume: number + sound_pack: string + sounds: Partial> } type TuiConfigView = Pick &