Skip to content

Commit 8cea2ce

Browse files
Apply PR #26980: tui notifications (WIP)
2 parents ed95725 + 3212f8c commit 8cea2ce

67 files changed

Lines changed: 1551 additions & 28 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bun.lock

Lines changed: 16 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@
3535
"@types/cross-spawn": "6.0.6",
3636
"@octokit/rest": "22.0.0",
3737
"@hono/zod-validator": "0.4.2",
38-
"@opentui/core": "0.2.6",
39-
"@opentui/keymap": "0.2.6",
40-
"@opentui/solid": "0.2.6",
38+
"@opentui/core": "0.2.7",
39+
"@opentui/keymap": "0.2.7",
40+
"@opentui/solid": "0.2.7",
4141
"ulid": "3.0.1",
4242
"@kobalte/core": "0.13.11",
4343
"@types/luxon": "3.7.1",

packages/opencode/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
"@opencode-ai/plugin": "workspace:*",
109109
"@opencode-ai/script": "workspace:*",
110110
"@opencode-ai/sdk": "workspace:*",
111+
"@opencode-ai/ui": "workspace:*",
111112
"@openrouter/ai-sdk-provider": "2.8.1",
112113
"@opentelemetry/api": "1.9.0",
113114
"@opentelemetry/context-async-hooks": "2.6.1",

packages/opencode/specs/tui-plugins.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ Example:
2929
"plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]],
3030
"plugin_enabled": {
3131
"acme.demo": false
32+
},
33+
"attention": {
34+
"enabled": true,
35+
"notifications": true,
36+
"sound": true,
37+
"volume": 0.4,
38+
"sound_pack": "opencode.default",
39+
"sounds": {
40+
"error": "/Users/me/sounds/error.mp3"
41+
}
3242
}
3343
}
3444
```
@@ -45,6 +55,11 @@ Example:
4555
- Internal plugins can declare `enabled: false` to be registered but inactive by default; `plugin_enabled` and runtime KV can still enable them by id.
4656
- `plugin_enabled` is merged across config layers.
4757
- Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup.
58+
- `attention.enabled` disables all `api.attention.notify(...)` delivery when set to `false`.
59+
- `attention.notifications` and `attention.sound` independently control terminal-mediated desktop notifications and built-in sounds.
60+
- `attention.volume` sets the default built-in sound volume from `0` to `1`.
61+
- `attention.sound_pack` selects the initial semantic sound pack. Persisted runtime selection in KV can override it.
62+
- `attention.sounds` overrides individual semantic sound slots such as `error` or `done`.
4863
- `leader_timeout` is a top-level TUI setting.
4964
- `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).
5065
- `keybinds.leader` sets the key used by `<leader>` shortcuts.
@@ -212,6 +227,7 @@ That is what makes local config-scoped plugins able to import `@opencode-ai/plug
212227
Top-level API groups exposed to `tui(api, options, meta)`:
213228

214229
- `api.app.version`
230+
- `api.attention.notify(input)`
215231
- `api.keys.formatSequence(parts)`, `formatBindings(bindings)`
216232
- `api.keymap`
217233
- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
@@ -246,6 +262,24 @@ Top-level API groups exposed to `tui(api, options, meta)`:
246262
- `formatBindings(bindings)` formats binding lists and returns `undefined` when there is nothing to show.
247263
- For generic config-to-bindings helpers, import `createBindingLookup` from `@opencode-ai/plugin/tui`.
248264

265+
### Attention
266+
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"`.
269+
- `when: "always"` requests delivery regardless of terminal focus state.
270+
- `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.
272+
- Semantic sound names are `"default"`, `"question"`, `"permission"`, `"error"`, and `"done"`.
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.
275+
- `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.
276+
- `api.attention.soundboard.activate(id, { persist })` selects the active pack. `persist: true` writes the selected pack id to TUI KV state, not `tui.json`.
277+
- `api.attention.soundboard.current()` and `list()` expose the active/registered packs for plugin UX.
278+
- Config `attention.sounds` overrides active-pack sounds by slot. Failed loads fall back to the active pack and then `opencode.default`.
279+
- The host strips ANSI/control characters and collapses newlines before sending text to the terminal notification API.
280+
- Terminal and OS settings decide whether a requested notification is visibly displayed.
281+
- 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.
282+
249283
### Routes
250284

251285
- Reserved route names: `home` and `session`.

packages/opencode/src/audio.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ declare module "*.wav" {
33
export default file
44
}
55

6+
declare module "*.mp3" {
7+
const file: string
8+
export default file
9+
}
10+
611
declare module "*.wasm" {
712
const file: string
813
export default file

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui"
6363
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
6464
import { createTuiApi } from "@/cli/cmd/tui/plugin/api"
6565
import type { RouteMap } from "@/cli/cmd/tui/plugin/api"
66+
import { createTuiAttention } from "@/cli/cmd/tui/attention"
6667
import { FormatError, FormatUnknownError } from "@/cli/error"
6768
import { CommandPaletteDialog } from "./component/command-palette"
6869
import {
@@ -184,7 +185,6 @@ export function tui(input: {
184185
unguard?.()
185186
resolve()
186187
}
187-
188188
const onBeforeExit = async () => {
189189
offKeymap()
190190
modeStack.dispose()
@@ -290,6 +290,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
290290
routeRev()
291291
return routes.get(name)?.at(-1)?.render
292292
}
293+
const attention = createTuiAttention({ renderer, config: tuiConfig, kv })
293294

294295
const api = createTuiApi({
295296
tuiConfig,
@@ -305,11 +306,13 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
305306
theme: themeState,
306307
toast,
307308
renderer,
309+
attention,
308310
})
309311
const [ready, setReady] = createSignal(false)
310312
TuiPluginRuntime.init({
311313
api,
312314
config: tuiConfig,
315+
attention,
313316
})
314317
.catch((error) => {
315318
console.error("Failed to load TUI plugins", error)

0 commit comments

Comments
 (0)