Skip to content

Commit 680f1d9

Browse files
Apply PR #26980: tui notifications (WIP)
2 parents 471a496 + a35ad5f commit 680f1d9

70 files changed

Lines changed: 1612 additions & 197 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 & 22 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 & 1 deletion
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",
@@ -129,7 +130,6 @@
129130
"bonjour-service": "1.3.0",
130131
"bun-pty": "0.4.8",
131132
"chokidar": "4.0.3",
132-
"cli-sound": "1.1.3",
133133
"clipboardy": "4.0.0",
134134
"cross-spawn": "catalog:",
135135
"decimal.js": "10.5.0",

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: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@op
22
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
33
import * as Clipboard from "@tui/util/clipboard"
44
import * as Selection from "@tui/util/selection"
5+
import * as TuiAudio from "@tui/util/audio"
56
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
67
import { RouteProvider, useRoute } from "@tui/context/route"
78
import {
@@ -63,6 +64,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui"
6364
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
6465
import { createTuiApi } from "@/cli/cmd/tui/plugin/api"
6566
import type { RouteMap } from "@/cli/cmd/tui/plugin/api"
67+
import { createTuiAttention } from "@/cli/cmd/tui/attention"
6668
import { FormatError, FormatUnknownError } from "@/cli/error"
6769
import { CommandPaletteDialog } from "./component/command-palette"
6870
import {
@@ -183,11 +185,11 @@ export function tui(input: {
183185
unguard?.()
184186
resolve()
185187
}
186-
187188
const onBeforeExit = async () => {
188189
offKeymap()
189190
modeStack.dispose()
190191
await TuiPluginRuntime.dispose()
192+
TuiAudio.dispose()
191193
}
192194

193195
const renderer = await createCliRenderer(rendererConfig(input.config))
@@ -289,6 +291,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
289291
routeRev()
290292
return routes.get(name)?.at(-1)?.render
291293
}
294+
const attention = createTuiAttention({ renderer, config: tuiConfig, kv })
292295

293296
const api = createTuiApi({
294297
tuiConfig,
@@ -304,11 +307,13 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
304307
theme: themeState,
305308
toast,
306309
renderer,
310+
attention,
307311
})
308312
const [ready, setReady] = createSignal(false)
309313
TuiPluginRuntime.init({
310314
api,
311315
config: tuiConfig,
316+
attention,
312317
})
313318
.catch((error) => {
314319
console.error("Failed to load TUI plugins", error)

0 commit comments

Comments
 (0)