Skip to content

Commit bb9d43d

Browse files
committed
api
1 parent 4bc2c25 commit bb9d43d

4 files changed

Lines changed: 107 additions & 34 deletions

File tree

packages/opencode/specs/tui-plugins.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -264,13 +264,14 @@ Top-level API groups exposed to `tui(api, options, meta)`:
264264

265265
### Attention
266266

267-
- `api.attention.notify({ title?, message, sound?, when? })` requests user attention while keeping terminal focus, notifications, and audio owned by the host.
268-
- `message` is required; `title` defaults to `"opencode"`; `when` defaults to `"always"`; `sound` defaults to `false`.
267+
- `api.attention.notify({ title?, message, notification?, sound? })` requests user attention while keeping terminal focus, notifications, and audio owned by the host.
268+
- `message` is required; `title` defaults to `"opencode"`; `notification` defaults to enabled with `when: "blurred"`; `sound` defaults to enabled with `when: "always"`.
269269
- `when: "always"` requests delivery regardless of terminal focus state.
270270
- `when: "focused"` only requests delivery after the terminal is known focused; `when: "blurred"` only requests delivery after the terminal is known blurred.
271+
- Example: `notification: { when: "blurred" }, sound: { name: "question", when: "always" }` plays sound while focused but only triggers system notifications when blurred.
271272
- Semantic sound names are `"default"`, `"question"`, `"permission"`, `"error"`, and `"done"`.
272-
- `sound: true` plays the `"default"` sound; `sound: "question"` or `sound: { name: "question" }` plays a named semantic sound.
273-
- `sound: { volume }` overrides volume for that call; `sound: { enabled: false }` disables sound for that call.
273+
- `sound: true` plays the `"default"` sound; `sound: { name: "question" }` plays a named semantic sound.
274+
- `sound: { volume }` overrides volume for that call; `sound: false` disables sound for that call; `notification: false` disables system notification for that call.
274275
- `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.
275276
- `api.attention.soundboard.activate(id, { persist })` selects the active pack. `persist: true` writes the selected pack id to TUI KV state, not `tui.json`.
276277
- `api.attention.soundboard.current()` and `list()` expose the active/registered packs for plugin UX.

packages/opencode/src/cli/cmd/tui/attention.ts

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
TuiAttentionNotifyInput,
55
TuiAttentionNotifyResult,
66
TuiAttentionNotifySkipReason,
7+
TuiAttentionWhen,
78
TuiKV,
89
TuiAttentionSoundName,
910
TuiAttentionSoundPack,
@@ -95,19 +96,32 @@ function clampVolume(volume: number) {
9596

9697
function soundVolume(input: TuiAttentionNotifyInput, config: Pick<TuiConfig.Resolved, "attention">) {
9798
if (!config.attention.sound) return
98-
if (input.sound === undefined || input.sound === false) return
99+
if (input.sound === false) return
100+
if (input.sound === undefined) return clampVolume(config.attention.volume)
99101
if (input.sound === true) return clampVolume(config.attention.volume)
100-
if (typeof input.sound === "string") return clampVolume(config.attention.volume)
101-
if (input.sound.enabled === false) return
102102
return clampVolume(input.sound.volume ?? config.attention.volume)
103103
}
104104

105105
function soundName(input: TuiAttentionNotifyInput): TuiAttentionSoundName {
106-
if (typeof input.sound === "string") return isSoundName(input.sound) ? input.sound : "default"
107106
if (typeof input.sound === "object") return input.sound.name && isSoundName(input.sound.name) ? input.sound.name : "default"
108107
return "default"
109108
}
110109

110+
function notificationEnabled(input: TuiAttentionNotifyInput) {
111+
if (input.notification === false) return false
112+
return true
113+
}
114+
115+
function notificationWhen(input: TuiAttentionNotifyInput) {
116+
if (typeof input.notification === "object" && input.notification.when) return input.notification.when
117+
return "blurred"
118+
}
119+
120+
function soundWhen(input: TuiAttentionNotifyInput) {
121+
if (typeof input.sound === "object" && input.sound.when) return input.sound.when
122+
return "always"
123+
}
124+
111125
function isSoundName(value: string): value is TuiAttentionSoundName {
112126
return SOUND_NAMES.includes(value as TuiAttentionSoundName)
113127
}
@@ -127,8 +141,8 @@ function normalizePack(pack: TuiAttentionSoundPack): RegisteredSoundPack | undef
127141
}
128142
}
129143

130-
function focusSkip(when: TuiAttentionNotifyInput["when"], focus: FocusState) {
131-
if ((when ?? "always") === "always") return
144+
function focusSkip(when: TuiAttentionWhen, focus: FocusState) {
145+
if (when === "always") return
132146
if (focus === "unknown") return "focus_unknown"
133147
if (when === "blurred" && focus === "focused") return "focused"
134148
if (when === "focused" && focus === "blurred") return "blurred"
@@ -225,10 +239,10 @@ export function createTuiAttention(input: {
225239
const message = normalizeText(request.message, "", MESSAGE_LIMIT)
226240
if (!message) return skipped("empty_message")
227241

228-
const skip = focusSkip(request.when, focus)
229-
if (skip) return skipped(skip)
230-
231-
const notification = input.config.attention.notifications
242+
const notificationSkip = focusSkip(notificationWhen(request), focus)
243+
const notificationRequested = input.config.attention.notifications && notificationEnabled(request)
244+
const shouldNotify = notificationRequested && !notificationSkip
245+
const notification = shouldNotify
232246
? (() => {
233247
try {
234248
return input.renderer.triggerNotification(message, normalizeText(request.title, DEFAULT_TITLE, TITLE_LIMIT))
@@ -239,7 +253,13 @@ export function createTuiAttention(input: {
239253
})()
240254
: false
241255
const volume = soundVolume(request, input.config)
242-
const sound = volume === undefined ? false : await playSound(soundName(request), volume)
256+
const soundSkip = volume === undefined ? undefined : focusSkip(soundWhen(request), focus)
257+
const sound = volume === undefined || soundSkip ? false : await playSound(soundName(request), volume)
258+
259+
if (!notification && !sound) {
260+
if (notificationRequested && notificationSkip) return skipped(notificationSkip)
261+
if (soundSkip) return skipped(soundSkip)
262+
}
243263

244264
return {
245265
ok: notification || sound,

packages/opencode/test/cli/cmd/tui/attention.test.ts

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -143,17 +143,17 @@ function config(attention: Partial<AttentionConfig["attention"]> = {}): Attentio
143143
}
144144

145145
describe("createTuiAttention", () => {
146-
test("defaults to always and delivers before focus is known", async () => {
146+
test("defaults to sound always and notification blurred", async () => {
147147
const renderer = new FakeRenderer()
148148
const audio = new FakeAudio()
149149
const attention = createTuiAttention({ renderer, config: config(), audio })
150150

151-
expect(await attention.notify({ message: "hello", sound: true })).toEqual({
151+
expect(await attention.notify({ message: "hello" })).toEqual({
152152
ok: true,
153-
notification: true,
153+
notification: false,
154154
sound: true,
155155
})
156-
expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello" }])
156+
expect(renderer.notifications).toHaveLength(0)
157157
expect(audio.createCalls).toBe(1)
158158
})
159159

@@ -162,21 +162,21 @@ describe("createTuiAttention", () => {
162162
const audio = new FakeAudio()
163163
const attention = createTuiAttention({ renderer, config: config(), audio })
164164

165-
expect(await attention.notify({ message: "unknown", sound: true, when: "blurred" })).toEqual({
165+
expect(await attention.notify({ message: "unknown", sound: { when: "blurred" } })).toEqual({
166166
ok: false,
167167
notification: false,
168168
sound: false,
169169
skipped: "focus_unknown",
170170
})
171171
renderer.emit("focus")
172-
expect(await attention.notify({ message: "focused", sound: true, when: "blurred" })).toEqual({
172+
expect(await attention.notify({ message: "focused", sound: { when: "blurred" } })).toEqual({
173173
ok: false,
174174
notification: false,
175175
sound: false,
176176
skipped: "focused",
177177
})
178178
renderer.emit("blur")
179-
expect(await attention.notify({ message: "blurred", sound: true, when: "blurred" })).toEqual({
179+
expect(await attention.notify({ message: "blurred", sound: { when: "blurred" } })).toEqual({
180180
ok: true,
181181
notification: true,
182182
sound: true,
@@ -188,35 +188,35 @@ describe("createTuiAttention", () => {
188188
const renderer = new FakeRenderer()
189189
const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudio() })
190190

191-
expect(await attention.notify({ message: "unknown", when: "focused" })).toEqual({
191+
expect(await attention.notify({ message: "unknown", notification: { when: "focused" }, sound: false })).toEqual({
192192
ok: false,
193193
notification: false,
194194
sound: false,
195195
skipped: "focus_unknown",
196196
})
197197
renderer.emit("blur")
198-
expect(await attention.notify({ message: "blurred", when: "focused" })).toEqual({
198+
expect(await attention.notify({ message: "blurred", notification: { when: "focused" }, sound: false })).toEqual({
199199
ok: false,
200200
notification: false,
201201
sound: false,
202202
skipped: "blurred",
203203
})
204204
renderer.emit("focus")
205-
expect(await attention.notify({ message: "focused", when: "focused" })).toEqual({
205+
expect(await attention.notify({ message: "focused", notification: { when: "focused" }, sound: false })).toEqual({
206206
ok: true,
207207
notification: true,
208208
sound: false,
209209
})
210210
expect(renderer.notifications).toEqual([{ title: "opencode", message: "focused" }])
211211
})
212212

213-
test("always requests still deliver while focused", async () => {
213+
test("notification can deliver while focused when requested", async () => {
214214
const renderer = new FakeRenderer()
215215
const audio = new FakeAudio()
216216
const attention = createTuiAttention({ renderer, config: config(), audio })
217217
renderer.emit("focus")
218218

219-
expect(await attention.notify({ message: "hello", sound: true })).toEqual({
219+
expect(await attention.notify({ message: "hello", notification: { when: "always" } })).toEqual({
220220
ok: true,
221221
notification: true,
222222
sound: true,
@@ -230,7 +230,7 @@ describe("createTuiAttention", () => {
230230
const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudio() })
231231
renderer.emit("blur")
232232

233-
expect(await attention.notify({ title: "opencode", message: "hello" })).toEqual({
233+
expect(await attention.notify({ title: "opencode", message: "hello", sound: false })).toEqual({
234234
ok: true,
235235
notification: true,
236236
sound: false,
@@ -244,7 +244,7 @@ describe("createTuiAttention", () => {
244244
const attention = createTuiAttention({ renderer, config: config(), audio })
245245
renderer.emit("focus")
246246

247-
expect(await attention.notify({ message: "hello", sound: true, when: "blurred" })).toEqual({
247+
expect(await attention.notify({ message: "hello", sound: { when: "blurred" } })).toEqual({
248248
ok: false,
249249
notification: false,
250250
sound: false,
@@ -254,6 +254,53 @@ describe("createTuiAttention", () => {
254254
expect(audio.createCalls).toBe(0)
255255
})
256256

257+
test("can play sound always while notification is blurred-only", async () => {
258+
const renderer = new FakeRenderer()
259+
const audio = new FakeAudio()
260+
const attention = createTuiAttention({ renderer, config: config(), audio })
261+
renderer.emit("focus")
262+
263+
expect(
264+
await attention.notify({
265+
message: "hello",
266+
sound: { name: "question" },
267+
}),
268+
).toEqual({
269+
ok: true,
270+
notification: false,
271+
sound: true,
272+
})
273+
expect(renderer.notifications).toHaveLength(0)
274+
expect(audio.createCalls).toBe(1)
275+
276+
renderer.emit("blur")
277+
expect(
278+
await attention.notify({
279+
message: "hello again",
280+
sound: { name: "question" },
281+
}),
282+
).toEqual({
283+
ok: true,
284+
notification: true,
285+
sound: true,
286+
})
287+
expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello again" }])
288+
})
289+
290+
test("can disable notification per call while still playing sound", async () => {
291+
const renderer = new FakeRenderer()
292+
const audio = new FakeAudio()
293+
const attention = createTuiAttention({ renderer, config: config(), audio })
294+
295+
expect(await attention.notify({ message: "hello", notification: false })).toEqual({
296+
ok: true,
297+
notification: false,
298+
sound: true,
299+
})
300+
expect(renderer.notifications).toHaveLength(0)
301+
expect(audio.createCalls).toBe(1)
302+
})
303+
257304
test("skips empty messages and disabled attention", async () => {
258305
const empty = new FakeRenderer()
259306
empty.emit("blur")
@@ -312,7 +359,7 @@ describe("createTuiAttention", () => {
312359
const audio = new FakeAudio()
313360
const attention = createTuiAttention({ renderer, config: config(), audio })
314361

315-
await attention.notify({ message: "unknown", sound: true, when: "blurred" })
362+
await attention.notify({ message: "unknown", sound: { when: "blurred" } })
316363
expect(audio.createCalls).toBe(0)
317364

318365
renderer.emit("blur")
@@ -409,7 +456,7 @@ describe("createTuiAttention", () => {
409456
})
410457
attention.soundboard.activate("acme.soft")
411458

412-
expect(await attention.notify({ message: "question", sound: "question" })).toEqual({
459+
expect(await attention.notify({ message: "question", sound: { name: "question" } })).toEqual({
413460
ok: true,
414461
notification: true,
415462
sound: true,

packages/plugin/src/tui.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,11 +231,16 @@ export type TuiAttentionSoundName = "default" | "question" | "permission" | "err
231231

232232
export type TuiAttentionSound =
233233
| boolean
234-
| TuiAttentionSoundName
235234
| {
236-
enabled?: boolean
237235
name?: TuiAttentionSoundName
238236
volume?: number
237+
when?: TuiAttentionWhen
238+
}
239+
240+
export type TuiAttentionNotification =
241+
| boolean
242+
| {
243+
when?: TuiAttentionWhen
239244
}
240245

241246
export type TuiAttentionSoundPack = {
@@ -265,8 +270,8 @@ export type TuiAttentionSoundboard = {
265270
export type TuiAttentionNotifyInput = {
266271
title?: string
267272
message: string
273+
notification?: TuiAttentionNotification
268274
sound?: TuiAttentionSound
269-
when?: TuiAttentionWhen
270275
}
271276

272277
export type TuiAttentionNotifySkipReason =

0 commit comments

Comments
 (0)