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" },
},
])
})
})

View File

@@ -1,14 +1,9 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import type { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import { createBindingLookup } from "@opentui/keymap/extras"
import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2"
import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui"
import { formatBindings } from "@/cli/cmd/run/keymap.shared"
import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo } from "@/cli/cmd/run/runtime.boot"
type RunBinding = Binding<Renderable, KeyEvent>
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
function model(id: string, providerID: string, context: number, variants?: Record<string, Record<string, never>>) {
return {
@@ -61,45 +56,37 @@ function model(id: string, providerID: string, context: number, variants?: Recor
}
}
function bindings(...keys: string[]) {
return keys.map((key) => ({ key }))
}
function config(input?: {
leader?: string
leaderTimeout?: number
diff_style?: "auto" | "stacked"
bindings?: Partial<{
commandList: RunBinding[]
variantCycle: RunBinding[]
interrupt: RunBinding[]
historyPrevious: RunBinding[]
historyNext: RunBinding[]
inputClear: RunBinding[]
inputSubmit: RunBinding[]
inputNewline: RunBinding[]
commandList: string[]
variantCycle: string[]
interrupt: string[]
historyPrevious: string[]
historyNext: string[]
inputClear: string[]
inputSubmit: string[]
inputNewline: string[]
}>
}): Resolved {
const bind = input?.bindings
const keybinds = TuiKeybind.Keybinds.parse({
...(input?.leader && { leader: input.leader }),
...(bind?.commandList && { command_list: bind.commandList }),
...(bind?.variantCycle && { variant_cycle: bind.variantCycle }),
...(bind?.interrupt && { session_interrupt: bind.interrupt }),
...(bind?.historyPrevious && { history_previous: bind.historyPrevious }),
...(bind?.historyNext && { history_next: bind.historyNext }),
...(bind?.inputClear && { input_clear: bind.inputClear }),
...(bind?.inputSubmit && { input_submit: bind.inputSubmit }),
...(bind?.inputNewline && { input_newline: bind.inputNewline }),
})
return {
return createTuiResolvedConfig({
diff_style: input?.diff_style,
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
commandMap: TuiKeybind.CommandMap,
bindingDefaults: TuiKeybind.bindingDefaults(),
}),
leader_timeout: input?.leaderTimeout ?? 2000,
}
leader_timeout: input?.leaderTimeout,
keybinds: {
...(input?.leader && { leader: input.leader }),
...(bind?.commandList && { command_list: bind.commandList }),
...(bind?.variantCycle && { variant_cycle: bind.variantCycle }),
...(bind?.interrupt && { session_interrupt: bind.interrupt }),
...(bind?.historyPrevious && { history_previous: bind.historyPrevious }),
...(bind?.historyNext && { history_next: bind.historyNext }),
...(bind?.inputClear && { input_clear: bind.inputClear }),
...(bind?.inputSubmit && { input_submit: bind.inputSubmit }),
...(bind?.inputNewline && { input_newline: bind.inputNewline }),
},
})
}
describe("run runtime boot", () => {
@@ -112,14 +99,14 @@ describe("run runtime boot", () => {
config({
leader: "ctrl+g",
bindings: {
commandList: bindings("ctrl+p"),
variantCycle: bindings("ctrl+t", "alt+t"),
interrupt: bindings("ctrl+c"),
historyPrevious: bindings("k"),
historyNext: bindings("j"),
inputClear: bindings("ctrl+l"),
inputSubmit: bindings("ctrl+s"),
inputNewline: bindings("alt+return"),
commandList: ["ctrl+p"],
variantCycle: ["ctrl+t", "alt+t"],
interrupt: ["ctrl+c"],
historyPrevious: ["k"],
historyNext: ["j"],
inputClear: ["ctrl+l"],
inputSubmit: ["ctrl+s"],
inputNewline: ["alt+return"],
},
}),
)

View File

@@ -3,6 +3,7 @@ import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { createTestKeymap } from "@opentui/keymap/testing"
import type { TuiAttentionSoundPack } from "@opencode-ai/plugin/tui"
import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig, mockTuiRuntime } from "../../fixture/tui-runtime"
@@ -854,6 +855,85 @@ test("plugin keymap proxy preserves real keymap receiver", async () => {
}
})
test("auto-disposes plugin attention sound packs and resolves sound paths", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "attention-soundpack-plugin.ts")
const spec = pathToFileURL(file).href
const absolute = path.join(dir, "sounds", "default.mp3")
const url = pathToFileURL(path.join(dir, "sounds", "error.mp3")).href
await Bun.write(
file,
`export default {
id: "demo.attention.soundpack",
tui: async (api) => {
api.attention.soundboard.registerPack({
id: "demo.pack",
sounds: {
default: ${JSON.stringify(absolute)},
question: "sounds/question.mp3",
done: " sounds/done.mp3 ",
subagent_done: "sounds/subagent-done.mp3",
error: ${JSON.stringify(url)},
nope: "sounds/nope.mp3",
permission: "",
},
})
},
}
`,
)
return { spec }
},
})
const packs: TuiAttentionSoundPack[] = []
let dropped = 0
const attention = {
soundboard: {
registerPack(pack: TuiAttentionSoundPack) {
packs.push(pack)
return () => {
dropped += 1
}
},
},
}
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: {
default: path.join(tmp.path, "sounds", "default.mp3"),
question: path.join(tmp.path, "sounds", "question.mp3"),
done: path.join(tmp.path, "sounds", "done.mp3"),
subagent_done: path.join(tmp.path, "sounds", "subagent-done.mp3"),
error: path.join(tmp.path, "sounds", "error.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) => {

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { pathToFileURL } from "url"
import { provideTestInstance, tmpdir } from "../fixture/fixture"
import { InstanceRuntime } from "@/project/instance-runtime"
import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
@@ -142,6 +143,59 @@ test("loads tui config with the same precedence order as server config paths", a
expect(config.diff_style).toBe("stacked")
})
test("resolves attention config defaults and overrides", async () => {
await using defaults = await tmpdir()
expect((await getTuiConfig(defaults.path)).attention).toEqual({
enabled: false,
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,
sound_pack: "acme.soft",
sounds: {
default: path.join(dir, "default.mp3"),
question: pathToFileURL(path.join(dir, "question.mp3")).href,
error: "./error.mp3",
subagent_done: "./subagent-done.mp3",
},
},
},
null,
2,
),
)
},
})
expect((await getTuiConfig(overridden.path)).attention).toEqual({
enabled: false,
notifications: false,
sound: false,
volume: 0.7,
sound_pack: "acme.soft",
sounds: {
default: path.join(overridden.path, "default.mp3"),
question: path.join(overridden.path, "question.mp3"),
error: path.join(overridden.path, "error.mp3"),
subagent_done: path.join(overridden.path, "subagent-done.mp3"),
},
})
})
test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => {
await using tmp = await tmpdir({
init: async (dir) => {

View File

@@ -12,6 +12,10 @@ type Count = {
command_drop: number
}
type AttentionOpts = Partial<Omit<HostPluginApi["attention"], "soundboard">> & {
soundboard?: Partial<HostPluginApi["attention"]["soundboard"]>
}
function themeCurrent(): HostPluginApi["theme"]["current"] {
const a = RGBA.fromInts(0, 120, 240)
const b = RGBA.fromInts(120, 120, 120)
@@ -83,6 +87,8 @@ function themeCurrent(): HostPluginApi["theme"]["current"] {
type Opts = {
client?: HostPluginApi["client"] | (() => HostPluginApi["client"])
renderer?: HostPluginApi["renderer"]
attention?: AttentionOpts
event?: HostPluginApi["event"]
count?: Count
keymap?: HostPluginApi["keymap"]
tuiConfig?: Partial<HostPluginApi["tuiConfig"]>
@@ -183,6 +189,17 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
return opts.app?.version ?? "0.0.0-test"
},
},
attention: {
async notify(input) {
return opts.attention?.notify?.(input) ?? { ok: false, notification: false, sound: false }
},
soundboard: {
registerPack: (pack) => opts.attention?.soundboard?.registerPack?.(pack) ?? (() => {}),
activate: (id, options) => opts.attention?.soundboard?.activate?.(id, options) ?? false,
current: () => opts.attention?.soundboard?.current?.() ?? "opencode.default",
list: () => opts.attention?.soundboard?.list?.() ?? [],
},
},
keys: {
formatSequence: () => "",
formatBindings: () => undefined,
@@ -190,7 +207,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
get client() {
return client()
},
event: {
event: opts.event ?? {
on: () => {
if (count) count.event_add += 1
return () => {

View File

@@ -5,7 +5,8 @@ import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
import { TuiKeybind } from "../../src/cli/cmd/tui/config/keybind"
type PluginSpec = string | [string, Record<string, unknown>]
type ResolvedInput = Omit<TuiConfig.Resolved, "keybinds" | "leader_timeout"> & {
type ResolvedInput = Omit<TuiConfig.Resolved, "attention" | "keybinds" | "leader_timeout"> & {
attention?: Partial<TuiConfig.Resolved["attention"]>
keybinds?: Partial<TuiKeybind.Keybinds>
leader_timeout?: number
}
@@ -22,6 +23,15 @@ export function createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Re
const keybinds = TuiKeybind.Keybinds.parse(input.keybinds ?? {})
return {
...input,
attention: {
enabled: false,
notifications: true,
sound: true,
volume: 0.4,
sound_pack: "opencode.default",
sounds: {},
...input.attention,
},
keybinds: createTuiResolvedKeybinds(keybinds),
leader_timeout: input.leader_timeout ?? 2000,
}