Add TUI notifications and attention sounds (disabled by default) (#26980)

This commit is contained in:
Sebastian
2026-05-13 03:26:25 +02:00
committed by GitHub
parent adccab5970
commit d6367853ae
73 changed files with 1856 additions and 480 deletions

View File

@@ -0,0 +1,484 @@
import { describe, expect, test } from "bun:test"
import type { AudioPlayOptions, AudioSound } from "@opentui/core"
import { createTuiAttention } from "@/cli/cmd/tui/attention"
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
type FocusEvent = "focus" | "blur"
type AttentionConfig = Pick<TuiConfig.Resolved, "attention">
class FakeRenderer {
isDestroyed = false
notificationResult = true
notificationThrows = false
notifications: { message: string; title: string | undefined }[] = []
listeners: Record<FocusEvent, Set<() => void>> = {
focus: new Set(),
blur: new Set(),
}
on(event: FocusEvent, listener: () => void) {
this.listeners[event].add(listener)
return this
}
off(event: FocusEvent, listener: () => void) {
this.listeners[event].delete(listener)
return this
}
emit(event: FocusEvent) {
for (const listener of this.listeners[event]) listener()
}
listenerCount(event: FocusEvent) {
return this.listeners[event].size
}
triggerNotification(message: string, title?: string) {
if (this.notificationThrows) throw new Error("notification failed")
this.notifications.push({ message, title })
return this.notificationResult
}
}
class FakeAudioEngine {
loadResult: AudioSound | null = 1
playResult: number | null = 1
loadCalls = 0
playCalls = 0
volumes: (number | undefined)[] = []
loadPaths: string[] = []
rejectLoad = false
rejectPaths = new Set<string>()
async loadSoundFile(path: string) {
this.loadCalls += 1
this.loadPaths.push(path)
if (this.rejectLoad || this.rejectPaths.has(path)) throw new Error("decode failed")
return this.loadResult
}
play(_sound: AudioSound, options?: AudioPlayOptions) {
this.playCalls += 1
this.volumes.push(options?.volume)
return this.playResult
}
}
class FakeKV {
store: Record<string, unknown> = {}
get ready() {
return true
}
get<Value = unknown>(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["attention"]> = {}): AttentionConfig {
return {
attention: {
enabled: true,
notifications: true,
sound: true,
volume: 0.4,
sound_pack: "opencode.default",
sounds: {},
...attention,
},
}
}
describe("createTuiAttention", () => {
test("defaults to sound always and notification blurred", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
const attention = createTuiAttention({ renderer, config: config(), audio })
expect(await attention.notify({ message: "hello" })).toEqual({
ok: true,
notification: false,
sound: true,
})
expect(renderer.notifications).toHaveLength(0)
expect(audio.playCalls).toBe(1)
})
test("supports blurred-only requests", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
const attention = createTuiAttention({ renderer, config: config(), audio })
expect(await attention.notify({ message: "unknown", sound: { when: "blurred" } })).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "focus_unknown",
})
renderer.emit("focus")
expect(await attention.notify({ message: "focused", sound: { when: "blurred" } })).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "focused",
})
renderer.emit("blur")
expect(await attention.notify({ message: "blurred", sound: { when: "blurred" } })).toEqual({
ok: true,
notification: true,
sound: true,
})
expect(audio.playCalls).toBe(1)
})
test("supports focused-only requests", async () => {
const renderer = new FakeRenderer()
const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudioEngine() })
expect(await attention.notify({ message: "unknown", notification: { when: "focused" }, sound: false })).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "focus_unknown",
})
renderer.emit("blur")
expect(await attention.notify({ message: "blurred", notification: { when: "focused" }, sound: false })).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "blurred",
})
renderer.emit("focus")
expect(await attention.notify({ message: "focused", notification: { when: "focused" }, sound: false })).toEqual({
ok: true,
notification: true,
sound: false,
})
expect(renderer.notifications).toEqual([{ title: "opencode", message: "focused" }])
})
test("notification can deliver while focused when requested", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
const attention = createTuiAttention({ renderer, config: config(), audio })
renderer.emit("focus")
expect(await attention.notify({ message: "hello", notification: { when: "always" } })).toEqual({
ok: true,
notification: true,
sound: true,
})
expect(audio.playCalls).toBe(1)
expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello" }])
})
test("notifies while blurred", async () => {
const renderer = new FakeRenderer()
const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudioEngine() })
renderer.emit("blur")
expect(await attention.notify({ title: "opencode", message: "hello", sound: false })).toEqual({
ok: true,
notification: true,
sound: false,
})
expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello" }])
})
test("when requested, blurred-only calls do not notify or play sound while focused", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
const attention = createTuiAttention({ renderer, config: config(), audio })
renderer.emit("focus")
expect(await attention.notify({ message: "hello", sound: { when: "blurred" } })).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "focused",
})
expect(renderer.notifications).toHaveLength(0)
expect(audio.loadCalls).toBe(0)
})
test("can play sound always while notification is blurred-only", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
const attention = createTuiAttention({ renderer, config: config(), audio })
renderer.emit("focus")
expect(
await attention.notify({
message: "hello",
sound: { name: "question" },
}),
).toEqual({
ok: true,
notification: false,
sound: true,
})
expect(renderer.notifications).toHaveLength(0)
expect(audio.playCalls).toBe(1)
renderer.emit("blur")
expect(
await attention.notify({
message: "hello again",
sound: { name: "question" },
}),
).toEqual({
ok: true,
notification: true,
sound: true,
})
expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello again" }])
})
test("can disable notification per call while still playing sound", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
const attention = createTuiAttention({ renderer, config: config(), audio })
expect(await attention.notify({ message: "hello", notification: false })).toEqual({
ok: true,
notification: false,
sound: true,
})
expect(renderer.notifications).toHaveLength(0)
expect(audio.playCalls).toBe(1)
})
test("skips empty messages and disabled attention", async () => {
const empty = new FakeRenderer()
empty.emit("blur")
const disabled = new FakeRenderer()
disabled.emit("blur")
expect(await createTuiAttention({ renderer: empty, config: config() }).notify({ message: " \n " })).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "empty_message",
})
expect(
await createTuiAttention({ renderer: disabled, config: config({ enabled: false }) }).notify({ message: "hello" }),
).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "attention_disabled",
})
})
test("respects notification and sound config independently", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
const attention = createTuiAttention({ renderer, config: config({ notifications: false }), audio })
renderer.emit("blur")
expect(await attention.notify({ message: "hello", sound: true })).toEqual({
ok: true,
notification: false,
sound: true,
})
expect(renderer.notifications).toHaveLength(0)
expect(audio.playCalls).toBe(1)
const soundDisabledRenderer = new FakeRenderer()
const soundDisabledAudio = new FakeAudioEngine()
const soundDisabled = createTuiAttention({
renderer: soundDisabledRenderer,
config: config({ sound: false }),
audio: soundDisabledAudio,
})
soundDisabledRenderer.emit("blur")
expect(await soundDisabled.notify({ message: "hello", sound: true })).toEqual({
ok: true,
notification: true,
sound: false,
})
expect(soundDisabledAudio.loadCalls).toBe(0)
})
test("loads audio lazily only for eligible sound requests", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
const attention = createTuiAttention({ renderer, config: config(), audio })
await attention.notify({ message: "unknown", sound: { when: "blurred" } })
expect(audio.loadCalls).toBe(0)
renderer.emit("blur")
expect(await attention.notify({ message: "blurred", sound: { volume: 2 } })).toEqual({
ok: true,
notification: true,
sound: true,
})
expect(audio.loadCalls).toBe(1)
expect(audio.volumes).toEqual([1])
})
test("handles unavailable playback and delegates sound loading", async () => {
const unavailableRenderer = new FakeRenderer()
const unavailableAudio = new FakeAudioEngine()
unavailableAudio.playResult = null
const unavailable = createTuiAttention({ renderer: unavailableRenderer, config: config(), audio: unavailableAudio })
unavailableRenderer.emit("blur")
expect(await unavailable.notify({ message: "hello", sound: true })).toEqual({
ok: true,
notification: true,
sound: false,
})
expect(unavailableAudio.loadCalls).toBe(1)
expect(unavailableAudio.playCalls).toBe(1)
const repeatedRenderer = new FakeRenderer()
const repeatedAudio = new FakeAudioEngine()
const repeated = createTuiAttention({ renderer: repeatedRenderer, config: config(), audio: repeatedAudio })
repeatedRenderer.emit("blur")
await repeated.notify({ message: "one", sound: true })
await repeated.notify({ message: "two", sound: true })
expect(repeatedAudio.loadCalls).toBe(2)
expect(repeatedAudio.playCalls).toBe(2)
})
test("plays named sounds from the active sound pack", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
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.loadPaths).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 FakeAudioEngine()
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: { name: "question" } })).toEqual({
ok: true,
notification: true,
sound: true,
})
expect(audio.loadPaths).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 FakeAudioEngine()
renderer.notificationThrows = true
audio.rejectLoad = true
const attention = createTuiAttention({ renderer, config: config(), audio })
renderer.emit("blur")
expect(await attention.notify({ message: "hello", sound: true })).toEqual({
ok: false,
notification: false,
sound: false,
})
})
test("strips unsafe notification text", async () => {
const renderer = new FakeRenderer()
const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudioEngine() })
renderer.emit("blur")
await attention.notify({
title: "\u001b[31m danger\n title\u0007",
message: "\u001b[32m hello\n world\u0000",
})
expect(renderer.notifications).toEqual([{ title: "danger title", message: "hello world" }])
})
test("disposes renderer listeners", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudioEngine()
const attention = createTuiAttention({ renderer, config: config(), audio })
renderer.emit("blur")
await attention.notify({ message: "hello", sound: true })
expect(renderer.listenerCount("focus")).toBe(1)
expect(renderer.listenerCount("blur")).toBe(1)
attention.dispose()
renderer.isDestroyed = true
expect(renderer.listenerCount("focus")).toBe(0)
expect(renderer.listenerCount("blur")).toBe(0)
expect(audio.loadCalls).toBe(1)
expect(await attention.notify({ message: "hello" })).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "renderer_destroyed",
})
})
})

View File

@@ -0,0 +1,267 @@
import { describe, expect, test } from "bun:test"
import Notifications from "@/cli/cmd/tui/feature-plugins/system/notifications"
import type { Event, PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2"
import type { TuiAttentionNotifyInput } from "@opencode-ai/plugin/tui"
import { createTuiPluginApi } from "../../../fixture/tui-plugin"
async function setup() {
const notifications: TuiAttentionNotifyInput[] = []
const handlers = new Map<Event["type"], ((event: Event) => void)[]>()
const session = (id: string, title: string, parentID?: string): Session => ({
id,
title,
slug: id,
projectID: "project",
directory: "/workspace",
...(parentID && { parentID }),
version: "0.0.0-test",
time: { created: 0, updated: 0 },
})
const sessions: Record<string, Session> = {
session: session("session", "Demo session"),
subagent: session("subagent", "Subagent session", "session"),
abort: session("abort", "Abort session"),
timeout: session("timeout", "Timeout session"),
}
await Notifications.tui(
createTuiPluginApi({
attention: {
async notify(input) {
notifications.push(input)
return { ok: true, notification: true, sound: true }
},
},
event: {
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => {
const list = handlers.get(type) ?? []
const wrapped = handler as (event: Event) => void
list.push(wrapped)
handlers.set(type, list)
return () => {
handlers.set(
type,
(handlers.get(type) ?? []).filter((item) => item !== wrapped),
)
}
},
},
state: {
session: {
get: (sessionID: string) => sessions[sessionID],
},
},
}),
undefined,
{} as never,
)
return {
notifications,
emit(event: Event) {
for (const handler of handlers.get(event.type) ?? []) handler(event)
},
}
}
function question(id: string, sessionID = "session"): QuestionRequest {
return {
id,
sessionID,
questions: [],
}
}
function permission(id: string, sessionID = "session"): PermissionRequest {
return {
id,
sessionID,
permission: "edit",
patterns: [],
metadata: {},
always: [],
}
}
const questionNotification: TuiAttentionNotifyInput = {
title: "Demo session",
message: "Question needs input",
notification: { when: "blurred" },
sound: { name: "question", when: "always" },
}
const permissionNotification: TuiAttentionNotifyInput = {
title: "Demo session",
message: "Permission needs input",
notification: { when: "blurred" },
sound: { name: "permission", when: "always" },
}
describe("internal notifications TUI plugin", () => {
test("notifies for question and permission requests with blurred notifications and always-on sounds", async () => {
const harness = await setup()
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
harness.emit({ id: "event-2", type: "permission.asked", properties: permission("permission-1") })
expect(harness.notifications).toEqual([questionNotification, permissionNotification])
})
test("dedupes pending questions and permissions until they are resolved", async () => {
const harness = await setup()
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
harness.emit({ id: "event-2", type: "question.asked", properties: question("question-1") })
harness.emit({
id: "event-3",
type: "question.replied",
properties: { sessionID: "session", requestID: "question-1", answers: [] },
})
harness.emit({ id: "event-4", type: "question.asked", properties: question("question-1") })
harness.emit({ id: "event-5", type: "permission.asked", properties: permission("permission-1") })
harness.emit({ id: "event-6", type: "permission.asked", properties: permission("permission-1") })
harness.emit({
id: "event-7",
type: "permission.replied",
properties: { sessionID: "session", requestID: "permission-1", reply: "once" },
})
harness.emit({ id: "event-8", type: "permission.asked", properties: permission("permission-1") })
expect(harness.notifications).toEqual([
questionNotification,
questionNotification,
permissionNotification,
permissionNotification,
])
})
test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
const harness = await setup()
harness.emit({
id: "event-1",
type: "session.status",
properties: { sessionID: "session", status: { type: "idle" } },
})
harness.emit({
id: "event-2",
type: "session.status",
properties: { sessionID: "session", status: { type: "busy" } },
})
harness.emit({
id: "event-3",
type: "session.status",
properties: { sessionID: "session", status: { type: "idle" } },
})
expect(harness.notifications).toEqual([
{
title: "Demo session",
message: "Session done",
notification: { when: "blurred" },
sound: { name: "done", when: "always" },
},
])
})
test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
const harness = await setup()
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1", "subagent") })
harness.emit({
id: "event-2",
type: "session.status",
properties: { sessionID: "subagent", status: { type: "busy" } },
})
harness.emit({
id: "event-3",
type: "session.status",
properties: { sessionID: "subagent", status: { type: "idle" } },
})
expect(harness.notifications).toEqual([
{
title: "Subagent session",
message: "Question needs input",
notification: false,
sound: { name: "question", when: "always" },
},
{
title: "Subagent session",
message: "Session done",
notification: false,
sound: { name: "subagent_done", when: "always" },
},
])
})
test("notifies session errors once and suppresses the following idle done notification", async () => {
const harness = await setup()
harness.emit({
id: "event-1",
type: "session.status",
properties: { sessionID: "session", status: { type: "busy" } },
})
harness.emit({
id: "event-2",
type: "session.error",
properties: { sessionID: "session", error: { name: "UnknownError", data: { message: "boom" } } },
})
harness.emit({
id: "event-3",
type: "session.status",
properties: { sessionID: "session", status: { type: "idle" } },
})
expect(harness.notifications).toEqual([
{
title: "Demo session",
message: "Session error",
notification: { when: "blurred" },
sound: { name: "error", when: "always" },
},
])
})
test("special-cases aborts and model response timeouts", async () => {
const harness = await setup()
harness.emit({
id: "event-1",
type: "session.status",
properties: { sessionID: "abort", status: { type: "busy" } },
})
harness.emit({
id: "event-2",
type: "session.error",
properties: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
})
harness.emit({
id: "event-3",
type: "session.status",
properties: { sessionID: "timeout", status: { type: "busy" } },
})
harness.emit({
id: "event-4",
type: "session.error",
properties: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
})
expect(harness.notifications).toEqual([
{
title: "Abort session",
message: "Session aborted",
notification: { when: "blurred" },
sound: { name: "error", when: "always" },
},
{
title: "Timeout session",
message: "Model stopped responding",
notification: { when: "blurred" },
sound: { name: "error", when: "always" },
},
])
})
})