Compare commits

...

10 Commits

Author SHA1 Message Date
Sebastian Herrlinger 3212f8c695 full opentui release 2026-05-12 04:58:12 +02:00
Sebastian Herrlinger 643800ef0d defaults 2026-05-12 04:44:53 +02:00
Sebastian Herrlinger 4d296b96cf notifications plugin 2026-05-12 04:44:53 +02:00
Sebastian Herrlinger 1d21c56e04 api 2026-05-12 04:44:52 +02:00
Sebastian Herrlinger 5d763d9ee7 soundpacks 2026-05-12 04:44:52 +02:00
Sebastian Herrlinger 9fdc885e1f opentui snapshot 2026-05-12 04:44:52 +02:00
Sebastian Herrlinger af50244427 attention control 2026-05-12 04:44:52 +02:00
Sebastian Herrlinger f6fcee9b9e attention api 2026-05-12 04:44:52 +02:00
Sebastian Herrlinger a7b44afdba opentui snapshot 2026-05-12 04:44:52 +02:00
Sebastian Herrlinger 3a5746ce49 mp3 audio 2026-05-12 04:44:52 +02:00
67 changed files with 1551 additions and 28 deletions
+16 -15
View File
@@ -399,6 +399,7 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@openrouter/ai-sdk-provider": "2.8.1",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
@@ -510,9 +511,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.6",
"@opentui/keymap": ">=0.2.6",
"@opentui/solid": ">=0.2.6",
"@opentui/core": ">=0.2.7",
"@opentui/keymap": ">=0.2.7",
"@opentui/solid": ">=0.2.7",
},
"optionalPeers": [
"@opentui/core",
@@ -692,9 +693,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.6",
"@opentui/keymap": "0.2.6",
"@opentui/solid": "0.2.6",
"@opentui/core": "0.2.7",
"@opentui/keymap": "0.2.7",
"@opentui/solid": "0.2.7",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1619,23 +1620,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.6", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.6", "@opentui/core-darwin-x64": "0.2.6", "@opentui/core-linux-arm64": "0.2.6", "@opentui/core-linux-x64": "0.2.6", "@opentui/core-win32-arm64": "0.2.6", "@opentui/core-win32-x64": "0.2.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dBpMaWVM7wtW2/2TlGPrkPjg6gOL3MVU/5XXk+U1LDJB8L4q4NeYWVdzfAVNcEvgmuuCy/cVqdY2D4ei+e7MMg=="],
"@opentui/core": ["@opentui/core@0.2.7", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.7", "@opentui/core-darwin-x64": "0.2.7", "@opentui/core-linux-arm64": "0.2.7", "@opentui/core-linux-x64": "0.2.7", "@opentui/core-win32-arm64": "0.2.7", "@opentui/core-win32-x64": "0.2.7" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-cnN6JcaGC7SeQzobBy/CHzqUAQFtypazuw1CjQBo7WwoOiLMGubt9W5FXeF0zIrSxH2Ed6NLWhPYRg7SD4629Q=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hR5nsxNj+059utzenTCF0kealUlibON6fLuebFUCGM/5kJnqa+shIh0XbUDFm0+F47vqVUgZufBdUuieQZIbvQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CAy6cL3byz2Xf6gFiJHBpcnsp/2ADEWLLOUokVypOyPLcy8GY3sPzlA4pkAjVGQMYQhDj+Y3+SXz4uTLt4AETg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-pJ/bH4WC/mbBaakM1YdH6TVo67jhy0KPd61bCz97w0I/PJGr8fmNKvhmMt/AwyFgOQi3FYZiEKLMpGdvUcSsrQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-K06h333rMkC9cyMJr/VvcRK3ik81Admd8ZsES5uf5YXWPdYhXGf75I1T8mKIThhUmoFLb8R5xqfuPmoocsjM7Q=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Pnd3kOxig8ii+/IqYheOPEgferylsQA0L6tKBnHQ9jRlCJOcu0Rv65Jepueh212vevdV9DzPURJnhejG06J6g=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-iYWGTztbdG9yYSB5Alxuo0dWAmkWQR0+/paNWUyPOocjigmKgMmACDtHgYqa7sxkIcWgmXljt/f8rgXDG4wdMg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-458Mx9tBzEPzfft8cSt5ZaIpEepoxBXBOL6AUVmDTKWaZ3uouraPcEKraGAyvOTDQp2XDI3R8c/2GdaR77FaUQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-tymBCfYbsDRfHQNXsolkFfaTEIDhemD4+1ZovUztQd7i+0Ggnu9WbPN1SNCiRz6PjrlaNeQzZE3Wl8FfVdw/cw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-BDUrdrT1RCcVnQoHJmUut4y811jDBAEtc6GJFB4Gs265Be8SrTjVCus6p2fSQ7j9sZQ1OcjO+5+4NkheSZICDQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-XLPJWdT8QOukrYDkpIng6+uNUlF66ByXcQlC3qA9JbrUTBetZhgXs8Q2jEjRfc+Ty3uh1iRSA6PgJGbbOK/f4Q=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-SUYAzRJ9TSoD2Qt8kn6FJz6dbTrFEPVig5mScB4zFGgGQO/Bbod2/Q31vLS/IQrX+FDb67WaErD+kuMCnMPPLA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.7", "", { "os": "win32", "cpu": "x64" }, "sha512-CzVGEfqysVk8Hxcj0RDv/DtXIM6iZmbmr23kW7y8CJMPtmV1gmKI4D9abVjynWJnGbaSBnDi43mgZnGMgOdyEg=="],
"@opentui/keymap": ["@opentui/keymap@0.2.6", "", { "dependencies": { "@opentui/core": "0.2.6" }, "peerDependencies": { "@opentui/react": "0.2.6", "@opentui/solid": "0.2.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-+6OYuedrFCKVo4ryGFNwws++2VOmPcXU3PwpY0mP47gYQY2nvQ+etWIs2Y7r5eMIqUfxVCldkKsrzcEcA4tb/A=="],
"@opentui/keymap": ["@opentui/keymap@0.2.7", "", { "dependencies": { "@opentui/core": "0.2.7" }, "peerDependencies": { "@opentui/react": "0.2.7", "@opentui/solid": "0.2.7", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-Eq3RpzANO4TzFI6RIuQQW9AzeISdHyw6cDAubShdTsj7PT07R/DM75yalJcycYtrxQsjM4Xqz/o1WN1u0tpTKQ=="],
"@opentui/solid": ["@opentui/solid@0.2.6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.6", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2y225WlOGi/fCaajkxBmLyVW8Cr+OmhowHdvrYcz5w2kBD15sKbJLIYu1G9DxceirT1uIyasGy2TGzRRcVkTDg=="],
"@opentui/solid": ["@opentui/solid@0.2.7", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.7", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-nlkx9HvuWaHtc5A8eUEAPNi+5+37LZS3ln73WRmtT5xin8LnQf+yhwopqGgPSnLq1ODLwhkKRdr/9JCDr2j7Bg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+3 -3
View File
@@ -35,9 +35,9 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.6",
"@opentui/keymap": "0.2.6",
"@opentui/solid": "0.2.6",
"@opentui/core": "0.2.7",
"@opentui/keymap": "0.2.7",
"@opentui/solid": "0.2.7",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
+1
View File
@@ -108,6 +108,7 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@openrouter/ai-sdk-provider": "2.8.1",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
+34
View File
@@ -29,6 +29,16 @@ Example:
"plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]],
"plugin_enabled": {
"acme.demo": false
},
"attention": {
"enabled": true,
"notifications": true,
"sound": true,
"volume": 0.4,
"sound_pack": "opencode.default",
"sounds": {
"error": "/Users/me/sounds/error.mp3"
}
}
}
```
@@ -45,6 +55,11 @@ Example:
- Internal plugins can declare `enabled: false` to be registered but inactive by default; `plugin_enabled` and runtime KV can still enable them by id.
- `plugin_enabled` is merged across config layers.
- Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup.
- `attention.enabled` disables all `api.attention.notify(...)` delivery when set to `false`.
- `attention.notifications` and `attention.sound` independently control terminal-mediated desktop notifications and built-in sounds.
- `attention.volume` sets the default built-in sound volume from `0` to `1`.
- `attention.sound_pack` selects the initial semantic sound pack. Persisted runtime selection in KV can override it.
- `attention.sounds` overrides individual semantic sound slots such as `error` or `done`.
- `leader_timeout` is a top-level TUI setting.
- `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).
- `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
Top-level API groups exposed to `tui(api, options, meta)`:
- `api.app.version`
- `api.attention.notify(input)`
- `api.keys.formatSequence(parts)`, `formatBindings(bindings)`
- `api.keymap`
- `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)`:
- `formatBindings(bindings)` formats binding lists and returns `undefined` when there is nothing to show.
- For generic config-to-bindings helpers, import `createBindingLookup` from `@opencode-ai/plugin/tui`.
### Attention
- `api.attention.notify({ title?, message, notification?, sound? })` requests user attention while keeping terminal focus, notifications, and audio owned by the host.
- `message` is required; `title` defaults to `"opencode"`; `notification` defaults to enabled with `when: "blurred"`; `sound` defaults to enabled with `when: "always"`.
- `when: "always"` requests delivery regardless of terminal focus state.
- `when: "focused"` only requests delivery after the terminal is known focused; `when: "blurred"` only requests delivery after the terminal is known blurred.
- Example: `notification: { when: "blurred" }, sound: { name: "question", when: "always" }` plays sound while focused but only triggers system notifications when blurred.
- Semantic sound names are `"default"`, `"question"`, `"permission"`, `"error"`, and `"done"`.
- `sound: true` plays the `"default"` sound; `sound: { name: "question" }` plays a named semantic sound.
- `sound: { volume }` overrides volume for that call; `sound: false` disables sound for that call; `notification: false` disables system notification for that call.
- `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.
- `api.attention.soundboard.activate(id, { persist })` selects the active pack. `persist: true` writes the selected pack id to TUI KV state, not `tui.json`.
- `api.attention.soundboard.current()` and `list()` expose the active/registered packs for plugin UX.
- Config `attention.sounds` overrides active-pack sounds by slot. Failed loads fall back to the active pack and then `opencode.default`.
- The host strips ANSI/control characters and collapses newlines before sending text to the terminal notification API.
- Terminal and OS settings decide whether a requested notification is visibly displayed.
- 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.
### Routes
- Reserved route names: `home` and `session`.
+5
View File
@@ -3,6 +3,11 @@ declare module "*.wav" {
export default file
}
declare module "*.mp3" {
const file: string
export default file
}
declare module "*.wasm" {
const file: string
export default file
+4 -1
View File
@@ -63,6 +63,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { createTuiApi } from "@/cli/cmd/tui/plugin/api"
import type { RouteMap } from "@/cli/cmd/tui/plugin/api"
import { createTuiAttention } from "@/cli/cmd/tui/attention"
import { FormatError, FormatUnknownError } from "@/cli/error"
import { CommandPaletteProvider, useCommandPalette } from "./context/command-palette"
import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencodeKeymap } from "./keymap"
@@ -176,7 +177,6 @@ export function tui(input: {
unguard?.()
resolve()
}
const onBeforeExit = async () => {
offKeymap()
await TuiPluginRuntime.dispose()
@@ -283,6 +283,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
routeRev()
return routes.get(name)?.at(-1)?.render
}
const attention = createTuiAttention({ renderer, config: tuiConfig, kv })
const api = createTuiApi({
tuiConfig,
@@ -298,11 +299,13 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
theme: themeState,
toast,
renderer,
attention,
})
const [ready, setReady] = createSignal(false)
TuiPluginRuntime.init({
api,
config: tuiConfig,
attention,
})
.catch((error) => {
console.error("Failed to load TUI plugins", error)
@@ -0,0 +1,330 @@
import { Audio, type AudioErrorContext, type AudioSound } from "@opentui/core"
import type {
TuiAttention,
TuiAttentionNotifyInput,
TuiAttentionNotifyResult,
TuiAttentionNotifySkipReason,
TuiAttentionWhen,
TuiKV,
TuiAttentionSoundName,
TuiAttentionSoundPack,
TuiAttentionSoundPackInfo,
} from "@opencode-ai/plugin/tui"
import stripAnsi from "strip-ansi"
import type { TuiConfig } from "./config/tui"
import defaultSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
import questionSoundPath from "@opencode-ai/ui/audio/bip-bop-03.mp3" with { type: "file" }
import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with { type: "file" }
import errorSoundPath from "@opencode-ai/ui/audio/nope-03.mp3" with { type: "file" }
import doneSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
import * as Log from "@opencode-ai/core/util/log"
type FocusState = "unknown" | "focused" | "blurred"
type AttentionRenderer = {
readonly isDestroyed: boolean
on(event: "focus" | "blur", listener: () => void): unknown
off(event: "focus" | "blur", listener: () => void): unknown
triggerNotification(message: string, title?: string): boolean
}
type AttentionAudioEngine = {
on(event: "error", listener: (error: Error, context: AudioErrorContext) => void): unknown
isStarted(): boolean
start(): boolean
loadSound(data: Uint8Array | ArrayBuffer): AudioSound | null
play(sound: AudioSound, options?: { volume?: number }): unknown | null
dispose(): void
}
type AttentionAudio = {
create(): AttentionAudioEngine
bytes(path: string): Promise<Uint8Array>
}
type RegisteredSoundPack = TuiAttentionSoundPack & {
builtin: boolean
}
export type TuiAttentionHost = TuiAttention & {
dispose(): void
}
const log = Log.create({ service: "tui.attention" })
const DEFAULT_TITLE = "opencode"
const DEFAULT_PACK_ID = "opencode.default"
const KV_SOUND_PACK = "attention_sound_pack"
const TITLE_LIMIT = 80
const MESSAGE_LIMIT = 240
const SOUND_NAMES = [
"default",
"question",
"permission",
"error",
"done",
] as const satisfies readonly TuiAttentionSoundName[]
const BUILTIN_PACK: RegisteredSoundPack = {
id: DEFAULT_PACK_ID,
name: "OpenCode Default",
builtin: true,
sounds: {
default: defaultSoundPath,
question: questionSoundPath,
permission: permissionSoundPath,
error: errorSoundPath,
done: doneSoundPath,
},
}
function skipped(reason: TuiAttentionNotifySkipReason): TuiAttentionNotifyResult {
return {
ok: false,
notification: false,
sound: false,
skipped: reason,
}
}
function normalizeText(input: string | undefined, fallback: string, limit: number) {
const text = stripAnsi(input ?? "")
.replace(/[ \t]*[\r\n]+[ \t]*/g, " ")
.replace(/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, "")
.trim()
const normalized = text.length ? text : fallback
return Array.from(normalized).slice(0, limit).join("")
}
function clampVolume(volume: number) {
if (!Number.isFinite(volume)) return 0
return Math.min(1, Math.max(0, volume))
}
function soundVolume(input: TuiAttentionNotifyInput, config: Pick<TuiConfig.Resolved, "attention">) {
if (!config.attention.sound) return
if (input.sound === false) return
if (input.sound === undefined) return clampVolume(config.attention.volume)
if (input.sound === true) return clampVolume(config.attention.volume)
return clampVolume(input.sound.volume ?? config.attention.volume)
}
function soundName(input: TuiAttentionNotifyInput): TuiAttentionSoundName {
if (typeof input.sound === "object")
return input.sound.name && isSoundName(input.sound.name) ? input.sound.name : "default"
return "default"
}
function notificationEnabled(input: TuiAttentionNotifyInput) {
if (input.notification === false) return false
return true
}
function notificationWhen(input: TuiAttentionNotifyInput) {
if (typeof input.notification === "object" && input.notification.when) return input.notification.when
return "blurred"
}
function soundWhen(input: TuiAttentionNotifyInput) {
if (typeof input.sound === "object" && input.sound.when) return input.sound.when
return "always"
}
function isSoundName(value: string): value is TuiAttentionSoundName {
return SOUND_NAMES.includes(value as TuiAttentionSoundName)
}
function normalizePack(pack: TuiAttentionSoundPack): RegisteredSoundPack | undefined {
const id = pack.id.trim()
if (!id) return
return {
id,
name: pack.name?.trim() || undefined,
builtin: false,
sounds: Object.fromEntries(
Object.entries(pack.sounds).filter(
(item): item is [TuiAttentionSoundName, string] =>
isSoundName(item[0]) && typeof item[1] === "string" && item[1].trim().length > 0,
),
),
}
}
function focusSkip(when: TuiAttentionWhen, focus: FocusState) {
if (when === "always") return
if (focus === "unknown") return "focus_unknown"
if (when === "blurred" && focus === "focused") return "focused"
if (when === "focused" && focus === "blurred") return "blurred"
}
export function createTuiAttention(input: {
renderer: AttentionRenderer
config: Pick<TuiConfig.Resolved, "attention">
kv?: TuiKV
audio?: AttentionAudio
}): TuiAttentionHost {
let focus: FocusState = "unknown"
let disposed = false
let audio: AttentionAudioEngine | undefined
let activePackID: string | undefined
const packs = new Map<string, RegisteredSoundPack>([[BUILTIN_PACK.id, BUILTIN_PACK]])
const sounds = new Map<string, Promise<AudioSound | null>>()
const audioInput =
input.audio ??
({
create: () => {
const engine = Audio.create({ autoStart: false })
engine.on("error", (error, context) => {
log.debug("attention audio error", { error, context })
})
return engine
},
bytes: (file) => Bun.file(file).bytes(),
} satisfies AttentionAudio)
const onFocus = () => {
focus = "focused"
}
const onBlur = () => {
focus = "blurred"
}
input.renderer.on("focus", onFocus)
input.renderer.on("blur", onBlur)
function configuredPackID() {
const stored = input.kv?.get<string | undefined>(KV_SOUND_PACK, undefined)
return activePackID ?? stored ?? input.config.attention.sound_pack
}
function currentPack() {
return packs.get(configuredPackID()) ?? BUILTIN_PACK
}
function soundCandidates(name: TuiAttentionSoundName) {
return [input.config.attention.sounds[name], currentPack().sounds[name], BUILTIN_PACK.sounds[name]].filter(
(item, index, list): item is string => typeof item === "string" && list.indexOf(item) === index,
)
}
async function loadSound(file: string) {
if (!audio) return null
const cached = sounds.get(file)
if (cached) return cached
const task = audioInput
.bytes(file)
.then((bytes) => audio?.loadSound(bytes) ?? null)
.catch((error) => {
log.debug("failed to load attention sound", { file, error })
return null
})
sounds.set(file, task)
return task
}
async function playSound(name: TuiAttentionSoundName, volume: number) {
try {
audio ??= audioInput.create()
if (!audio.isStarted() && !audio.start()) return false
for (const file of soundCandidates(name)) {
const current = await loadSound(file)
if (current == null) continue
if (audio.play(current, { volume }) != null) return true
}
return false
} catch (error) {
log.debug("failed to play attention sound", { error })
return false
}
}
return {
async notify(request) {
try {
if (!input.config.attention.enabled) return skipped("attention_disabled")
if (disposed || input.renderer.isDestroyed) return skipped("renderer_destroyed")
const message = normalizeText(request.message, "", MESSAGE_LIMIT)
if (!message) return skipped("empty_message")
const notificationSkip = focusSkip(notificationWhen(request), focus)
const notificationRequested = input.config.attention.notifications && notificationEnabled(request)
const shouldNotify = notificationRequested && !notificationSkip
const notification = shouldNotify
? (() => {
try {
return input.renderer.triggerNotification(
message,
normalizeText(request.title, DEFAULT_TITLE, TITLE_LIMIT),
)
} catch (error) {
log.debug("failed to trigger attention notification", { error })
return false
}
})()
: false
const volume = soundVolume(request, input.config)
const soundSkip = volume === undefined ? undefined : focusSkip(soundWhen(request), focus)
const sound = volume === undefined || soundSkip ? false : await playSound(soundName(request), volume)
if (!notification && !sound) {
if (notificationRequested && notificationSkip) return skipped(notificationSkip)
if (soundSkip) return skipped(soundSkip)
}
return {
ok: notification || sound,
notification,
sound,
}
} catch (error) {
log.debug("failed to handle attention notification", { error })
return {
ok: false,
notification: false,
sound: false,
}
}
},
soundboard: {
registerPack(pack) {
const next = normalizePack(pack)
if (!next) return () => {}
packs.set(next.id, next)
let disposed = false
return () => {
if (disposed) return
disposed = true
if (packs.get(next.id) === next) packs.delete(next.id)
}
},
activate(id, options) {
const pack = packs.get(id)
if (!pack) return false
activePackID = pack.id
if (options?.persist) input.kv?.set(KV_SOUND_PACK, pack.id)
return true
},
current() {
return currentPack().id
},
list(): TuiAttentionSoundPackInfo[] {
const current = currentPack().id
return Array.from(packs.values()).map((pack) => ({
id: pack.id,
name: pack.name,
active: pack.id === current,
builtin: pack.builtin,
}))
},
},
dispose() {
disposed = true
input.renderer.off("focus", onFocus)
input.renderer.off("blur", onBlur)
audio?.dispose()
audio = undefined
sounds.clear()
},
}
}
@@ -7,6 +7,17 @@ const KeymapLeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({
description: "Leader key timeout in milliseconds",
})
export const TuiAttentionSoundNames = ["default", "question", "permission", "error", "done"] as const
export type TuiAttentionSoundName = (typeof TuiAttentionSoundNames)[number]
const TuiAttentionSounds = Schema.Struct({
default: Schema.optional(Schema.String),
question: Schema.optional(Schema.String),
permission: Schema.optional(Schema.String),
error: Schema.optional(Schema.String),
done: Schema.optional(Schema.String),
})
export const ScrollSpeed = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))
export const ScrollAcceleration = Schema.Struct({
@@ -17,6 +28,15 @@ export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({
description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
})
export const Attention = Schema.Struct({
enabled: Schema.optional(Schema.Boolean),
notifications: Schema.optional(Schema.Boolean),
sound: Schema.optional(Schema.Boolean),
volume: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1))),
sound_pack: Schema.optional(Schema.String),
sounds: Schema.optional(TuiAttentionSounds),
}).annotate({ description: "Attention notification and sound settings" })
export const TuiInfo = Schema.Struct({
$schema: Schema.optional(Schema.String),
theme: Schema.optional(Schema.String),
@@ -24,6 +44,7 @@ export const TuiInfo = Schema.Struct({
plugin: Schema.optional(Schema.Array(ConfigPlugin.Spec)),
plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
leader_timeout: Schema.optional(KeymapLeaderTimeout),
attention: Schema.optional(Attention),
scroll_speed: Schema.optional(ScrollSpeed).annotate({
description: "TUI scroll speed",
}),
@@ -1,5 +1,7 @@
export * as TuiConfig from "./tui"
import { fileURLToPath } from "url"
import path from "path"
import { createBindingLookup } from "@opentui/keymap/extras"
import { mergeDeep, unique } from "remeda"
import { Context, Effect, Fiber, Layer, Schema } from "effect"
@@ -7,7 +9,7 @@ import { ConfigParse } from "@/config/parse"
import { InvalidError } from "@/config/error"
import * as ConfigPaths from "@/config/paths"
import { migrateTuiConfig } from "./tui-migrate"
import { KeymapLeaderTimeoutDefault, TuiInfo } from "./tui-schema"
import { KeymapLeaderTimeoutDefault, TuiAttentionSoundNames, TuiInfo } from "./tui-schema"
import { Flag } from "@opencode-ai/core/flag/flag"
import { isRecord } from "@/util/record"
import { Global } from "@opencode-ai/core/global"
@@ -33,7 +35,15 @@ type Acc = {
plugin_origins: ConfigPlugin.Origin[]
}
export type Resolved = Omit<Info, "keybinds" | "leader_timeout"> & {
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout"> & {
attention: {
enabled: boolean
notifications: boolean
sound: boolean
volume: number
sound_pack: string
sounds: Partial<Record<(typeof TuiAttentionSoundNames)[number], string>>
}
keybinds: TuiKeybind.BindingLookupView
leader_timeout: number
// Internal resolved plugin list used by runtime loading.
@@ -69,6 +79,29 @@ function normalize(raw: Record<string, unknown>) {
}
}
function resolveSoundPath(value: string, configFilepath: string) {
const raw = value.startsWith("file://") ? fileURLToPath(value) : value
if (path.isAbsolute(raw)) return raw
return path.resolve(path.dirname(configFilepath), raw)
}
function resolveAttentionSounds(config: Info, configFilepath: string): Info {
if (!config.attention?.sounds) return config
return {
...config,
attention: {
...config.attention,
sounds: Object.fromEntries(
TuiAttentionSoundNames.flatMap((name) => {
const value = config.attention?.sounds?.[name]
if (!value) return []
return [[name, resolveSoundPath(value, configFilepath)]]
}),
),
},
}
}
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
const afs = yield* AppFileSystem.Service
@@ -101,7 +134,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
})
}
}
const validated = ConfigParse.schema(Info, normalized, configFilepath)
const validated = resolveAttentionSounds(ConfigParse.schema(Info, normalized, configFilepath), configFilepath)
return yield* resolvePlugins(validated, configFilepath)
}).pipe(
// catchCause (not tapErrorCause + orElseSucceed) because JSONC parsing and validation
@@ -197,6 +230,14 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
const parsedKeybinds = TuiKeybind.parse(keybinds)
const result: Resolved = {
...acc.result,
attention: {
enabled: acc.result.attention?.enabled ?? true,
notifications: acc.result.attention?.notifications ?? true,
sound: acc.result.attention?.sound ?? true,
volume: acc.result.attention?.volume ?? 0.4,
sound_pack: acc.result.attention?.sound_pack ?? "opencode.default",
sounds: acc.result.attention?.sounds ?? {},
},
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), {
commandMap: TuiKeybind.CommandMap,
bindingDefaults: TuiKeybind.bindingDefaults(),
@@ -0,0 +1,93 @@
import type { Event } from "@opencode-ai/sdk/v2"
import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { InternalTuiPlugin } from "../../plugin/internal"
const id = "internal:notifications"
type SessionError = Extract<Event, { type: "session.error" }>["properties"]["error"]
function notify(api: TuiPluginApi, message: string, sound: TuiAttentionSoundName) {
void api.attention.notify({
message,
notification: { when: "blurred" },
sound: { name: sound, when: "always" },
})
}
function errorDataMessage(error: SessionError) {
const data = error?.data
if (!data || typeof data !== "object" || !("message" in data)) return ""
return typeof data.message === "string" ? data.message : ""
}
function sessionErrorMessage(error: SessionError) {
if (error?.name === "MessageAbortedError") return "Session aborted"
if (errorDataMessage(error) === "SSE read timed out") return "Model stopped responding"
return "Session error"
}
const tui: TuiPlugin = async (api) => {
const active = new Set<string>()
const errored = new Set<string>()
const questions = new Set<string>()
const permissions = new Set<string>()
api.event.on("question.asked", (event) => {
if (questions.has(event.properties.id)) return
questions.add(event.properties.id)
notify(api, "Question needs input", "question")
})
api.event.on("question.replied", (event) => {
questions.delete(event.properties.requestID)
})
api.event.on("question.rejected", (event) => {
questions.delete(event.properties.requestID)
})
api.event.on("permission.asked", (event) => {
if (permissions.has(event.properties.id)) return
permissions.add(event.properties.id)
notify(api, "Permission needs input", "permission")
})
api.event.on("permission.replied", (event) => {
permissions.delete(event.properties.requestID)
})
api.event.on("session.status", (event) => {
const sessionID = event.properties.sessionID
if (event.properties.status.type === "busy" || event.properties.status.type === "retry") {
active.add(sessionID)
errored.delete(sessionID)
return
}
if (event.properties.status.type !== "idle") return
if (!active.has(sessionID)) return
active.delete(sessionID)
if (errored.has(sessionID)) {
errored.delete(sessionID)
return
}
notify(api, "Session done", "done")
})
api.event.on("session.error", (event) => {
const sessionID = event.properties.sessionID
if (!sessionID) return
if (!active.has(sessionID)) return
errored.add(sessionID)
notify(api, sessionErrorMessage(event.properties.error), "error")
})
}
const plugin: InternalTuiPlugin = {
id,
tui,
}
export default plugin
@@ -40,6 +40,7 @@ type Input = {
theme: ReturnType<typeof useTheme>
toast: ReturnType<typeof useToast>
renderer: TuiPluginApi["renderer"]
attention: TuiPluginApi["attention"]
}
function routeRegister(routes: RouteMap, list: TuiRouteDefinition[], bump: () => void) {
@@ -203,6 +204,7 @@ export function createTuiApi(input: Input): TuiPluginApi {
}
return {
app: appApi(),
attention: input.attention,
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds),
keys: {
@@ -7,6 +7,7 @@ import SidebarTodo from "../feature-plugins/sidebar/todo"
import SidebarFiles from "../feature-plugins/sidebar/files"
import SidebarFooter from "../feature-plugins/sidebar/footer"
import PluginManager from "../feature-plugins/system/plugins"
import Notifications from "../feature-plugins/system/notifications"
import SessionV2Debug from "../feature-plugins/system/session-v2"
import WhichKey from "../feature-plugins/system/which-key"
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
@@ -27,6 +28,7 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
SidebarTodo,
SidebarFiles,
SidebarFooter,
Notifications,
PluginManager,
WhichKey,
...(Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM ? [SessionV2Debug] : []),
@@ -37,6 +37,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
import { setupSlots, Slot as View } from "./slots"
import type { HostPluginApi, HostSlots } from "./slots"
import type { TuiAttentionHost } from "../attention"
import { ConfigPlugin } from "@/config/plugin"
import { createCommandShim } from "./command-shim"
@@ -106,6 +107,7 @@ const ScopedKeymapMethods = new Set<PropertyKey>([
type RuntimeState = {
directory: string
api: Api
attention?: TuiAttentionHost
slots: HostSlots
plugins: PluginEntry[]
plugins_by_id: Map<string, PluginEntry>
@@ -156,6 +158,39 @@ function createScopedKeymap(keymap: TuiPluginApi["keymap"], scope: PluginScope):
})
}
function createScopedAttention(
attention: TuiPluginApi["attention"],
scope: PluginScope,
root: string,
): TuiPluginApi["attention"] {
return {
notify(input) {
return attention.notify(input)
},
soundboard: {
registerPack(pack) {
return scope.track(
attention.soundboard.registerPack({
...pack,
sounds: Object.fromEntries(
Object.entries(pack.sounds).map(([name, file]) => [name, resolvePluginFile(root, file)]),
),
}),
)
},
activate(id, options) {
return attention.soundboard.activate(id, options)
},
current() {
return attention.soundboard.current()
},
list() {
return attention.soundboard.list()
},
},
}
}
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
@@ -197,6 +232,12 @@ function resolveRoot(root: string) {
return path.resolve(process.cwd(), root)
}
function resolvePluginFile(root: string, file: string) {
const raw = file.startsWith("file://") ? fileURLToPath(file) : file
if (path.isAbsolute(raw)) return raw
return path.resolve(root, raw)
}
function createThemeInstaller(
meta: ConfigPlugin.Origin,
root: string,
@@ -576,6 +617,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
return {
app: api.app,
attention: createScopedAttention(api.attention, scope, load.theme_root),
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds),
keys: api.keys,
@@ -967,7 +1009,7 @@ let loaded: Promise<void> | undefined
let runtime: RuntimeState | undefined
export const Slot = View
export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved }) {
export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved; attention?: TuiAttentionHost }) {
const cwd = process.cwd()
if (loaded) {
if (dir !== cwd) {
@@ -1014,15 +1056,17 @@ export async function dispose() {
for (const plugin of queue) {
await deactivatePluginEntry(state, plugin, false)
}
state.attention?.dispose()
}
async function load(input: { api: Api; config: TuiConfig.Resolved }) {
async function load(input: { api: Api; config: TuiConfig.Resolved; attention?: TuiAttentionHost }) {
const { api, config } = input
const cwd = process.cwd()
const slots = setupSlots(api)
const next: RuntimeState = {
directory: cwd,
api,
attention: input.attention,
slots,
plugins: [],
plugins_by_id: new Map(),
@@ -0,0 +1,535 @@
import { describe, expect, test } from "bun:test"
import type { AudioErrorContext, 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 {
started = false
startResult = true
loadResult: AudioSound | null = 1
playResult: number | null = 1
startCalls = 0
startMixerCalls = 0
loadCalls = 0
playCalls = 0
disposeCalls = 0
volumes: (number | undefined)[] = []
errorListenerCount = 0
on(_event: "error", _listener: (error: Error, context: AudioErrorContext) => void) {
this.errorListenerCount += 1
return this
}
isStarted() {
return this.started
}
start() {
this.startCalls += 1
this.started = this.startResult
return this.startResult
}
startMixer() {
this.startMixerCalls += 1
return true
}
loadSound(_data: Uint8Array | ArrayBuffer) {
this.loadCalls += 1
return this.loadResult
}
play(_sound: AudioSound, options?: { volume?: number }) {
this.playCalls += 1
this.volumes.push(options?.volume)
return this.playResult
}
dispose() {
this.disposeCalls += 1
}
}
class FakeAudio {
engine = new FakeAudioEngine()
createCalls = 0
bytesCalls = 0
bytesPaths: string[] = []
rejectBytes = false
rejectPaths = new Set<string>()
create() {
this.createCalls += 1
return this.engine
}
async bytes(path: string) {
this.bytesCalls += 1
this.bytesPaths.push(path)
if (this.rejectBytes || this.rejectPaths.has(path)) throw new Error("decode failed")
return new Uint8Array([1, 2, 3])
}
}
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 FakeAudio()
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.createCalls).toBe(1)
})
test("supports blurred-only requests", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudio()
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.createCalls).toBe(1)
})
test("supports focused-only requests", async () => {
const renderer = new FakeRenderer()
const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudio() })
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 FakeAudio()
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.createCalls).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 FakeAudio() })
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 FakeAudio()
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.createCalls).toBe(0)
})
test("can play sound always while notification is blurred-only", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudio()
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.createCalls).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 FakeAudio()
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.createCalls).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 FakeAudio()
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.engine.playCalls).toBe(1)
const soundDisabledRenderer = new FakeRenderer()
const soundDisabledAudio = new FakeAudio()
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.createCalls).toBe(0)
})
test("initializes audio lazily only for eligible sound requests", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudio()
const attention = createTuiAttention({ renderer, config: config(), audio })
await attention.notify({ message: "unknown", sound: { when: "blurred" } })
expect(audio.createCalls).toBe(0)
renderer.emit("blur")
expect(await attention.notify({ message: "blurred", sound: { volume: 2 } })).toEqual({
ok: true,
notification: true,
sound: true,
})
expect(audio.createCalls).toBe(1)
expect(audio.engine.startCalls).toBe(1)
expect(audio.engine.startMixerCalls).toBe(0)
expect(audio.engine.loadCalls).toBe(1)
expect(audio.engine.volumes).toEqual([1])
})
test("handles unavailable or already-started audio correctly", async () => {
const unavailableRenderer = new FakeRenderer()
const unavailableAudio = new FakeAudio()
unavailableAudio.engine.startResult = false
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.engine.loadCalls).toBe(0)
expect(unavailableAudio.engine.playCalls).toBe(0)
const startedRenderer = new FakeRenderer()
const startedAudio = new FakeAudio()
startedAudio.engine.started = true
const started = createTuiAttention({ renderer: startedRenderer, config: config(), audio: startedAudio })
startedRenderer.emit("blur")
await started.notify({ message: "one", sound: true })
await started.notify({ message: "two", sound: true })
expect(startedAudio.engine.startCalls).toBe(0)
expect(startedAudio.engine.loadCalls).toBe(1)
expect(startedAudio.engine.playCalls).toBe(2)
})
test("plays named sounds from the active sound pack", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudio()
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.bytesPaths).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 FakeAudio()
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.bytesPaths).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 FakeAudio()
renderer.notificationThrows = true
audio.rejectBytes = 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 FakeAudio() })
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 and audio", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudio()
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.engine.disposeCalls).toBe(1)
expect(await attention.notify({ message: "hello" })).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "renderer_destroyed",
})
})
})
@@ -0,0 +1,183 @@
import { describe, expect, test } from "bun:test"
import Notifications from "@/cli/cmd/tui/feature-plugins/system/notifications"
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
import type { TuiAttentionNotifyInput, TuiPluginApi } from "@opencode-ai/plugin/tui"
class Harness {
notifications: TuiAttentionNotifyInput[] = []
private handlers = new Map<Event["type"], ((event: Event) => void)[]>()
api() {
return {
attention: {
notify: async (input: TuiAttentionNotifyInput) => {
this.notifications.push(input)
return { ok: true, notification: true, sound: true }
},
soundboard: {
registerPack: () => () => {},
activate: () => false,
current: () => "opencode.default",
list: () => [],
},
},
event: {
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => {
const list = this.handlers.get(type) ?? []
const wrapped = handler as (event: Event) => void
list.push(wrapped)
this.handlers.set(type, list)
return () => {
this.handlers.set(
type,
(this.handlers.get(type) ?? []).filter((item) => item !== wrapped),
)
}
},
},
} as unknown as TuiPluginApi
}
emit(event: Event) {
for (const handler of this.handlers.get(event.type) ?? []) handler(event)
}
}
function question(id: string): QuestionRequest {
return {
id,
sessionID: "session",
questions: [],
}
}
function permission(id: string): PermissionRequest {
return {
id,
sessionID: "session",
permission: "edit",
patterns: [],
metadata: {},
always: [],
}
}
async function setup() {
const harness = new Harness()
await Notifications.tui(harness.api(), undefined, {} as never)
return harness
}
const questionNotification: TuiAttentionNotifyInput = {
message: "Question needs input",
notification: { when: "blurred" },
sound: { name: "question", when: "always" },
}
const permissionNotification: TuiAttentionNotifyInput = {
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([
{
message: "Session done",
notification: { when: "blurred" },
sound: { name: "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([
{
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([
{
message: "Session aborted",
notification: { when: "blurred" },
sound: { name: "error", when: "always" },
},
{
message: "Model stopped responding",
notification: { when: "blurred" },
sound: { name: "error", when: "always" },
},
])
})
})
@@ -93,6 +93,14 @@ function config(input?: {
...(bind?.inputNewline && { input_newline: bind.inputNewline }),
})
return {
attention: {
enabled: true,
notifications: true,
sound: true,
volume: 0.4,
sound_pack: "opencode.default",
sounds: {},
},
diff_style: input?.diff_style,
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
commandMap: TuiKeybind.CommandMap,
@@ -854,6 +854,75 @@ test("plugin keymap proxy preserves real keymap receiver", async () => {
}
})
test("auto-disposes plugin attention sound packs and resolves relative paths", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "attention-soundpack-plugin.ts")
const spec = pathToFileURL(file).href
await Bun.write(
file,
`export default {
id: "demo.attention.soundpack",
tui: async (api) => {
api.attention.soundboard.registerPack({
id: "demo.pack",
sounds: { question: "sounds/question.mp3" },
})
},
}
`,
)
return { spec }
},
})
const packs: Array<{ id: string; sounds: Record<string, string> }> = []
let dropped = 0
const attention = {
async notify() {
return { ok: false, notification: false, sound: false }
},
soundboard: {
registerPack(pack: { id: string; sounds: Record<string, string> }) {
packs.push(pack)
return () => {
dropped += 1
}
},
activate: () => false,
current: () => "opencode.default",
list: () => [],
},
} as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["attention"]
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: { question: path.join(tmp.path, "sounds", "question.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) => {
+43
View File
@@ -142,6 +142,49 @@ 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: true,
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: { error: "./error.mp3" },
},
},
null,
2,
),
)
},
})
expect((await getTuiConfig(overridden.path)).attention).toEqual({
enabled: false,
notifications: false,
sound: false,
volume: 0.7,
sound_pack: "acme.soft",
sounds: { error: path.join(overridden.path, "error.mp3") },
})
})
test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -83,6 +83,7 @@ function themeCurrent(): HostPluginApi["theme"]["current"] {
type Opts = {
client?: HostPluginApi["client"] | (() => HostPluginApi["client"])
renderer?: HostPluginApi["renderer"]
attention?: HostPluginApi["attention"]
count?: Count
keymap?: HostPluginApi["keymap"]
tuiConfig?: Partial<HostPluginApi["tuiConfig"]>
@@ -183,6 +184,23 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
return opts.app?.version ?? "0.0.0-test"
},
},
attention:
opts.attention ??
{
async notify() {
return {
ok: false,
notification: false,
sound: false,
}
},
soundboard: {
registerPack: () => () => {},
activate: () => false,
current: () => "opencode.default",
list: () => [],
},
},
keys: {
formatSequence: () => "",
formatBindings: () => undefined,
+11 -1
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: true,
notifications: true,
sound: true,
volume: 0.4,
sound_pack: "opencode.default",
sounds: {},
...input.attention,
},
keybinds: createTuiResolvedKeybinds(keybinds),
leader_timeout: input.leader_timeout ?? 2000,
}
+3 -3
View File
@@ -22,9 +22,9 @@
"zod": "catalog:"
},
"peerDependencies": {
"@opentui/core": ">=0.2.6",
"@opentui/keymap": ">=0.2.6",
"@opentui/solid": ">=0.2.6"
"@opentui/core": ">=0.2.7",
"@opentui/keymap": ">=0.2.7",
"@opentui/solid": ">=0.2.7"
},
"peerDependenciesMeta": {
"@opentui/core": {
+80
View File
@@ -225,6 +225,75 @@ export type TuiToast = {
duration?: number
}
export type TuiAttentionWhen = "always" | "focused" | "blurred"
export type TuiAttentionSoundName = "default" | "question" | "permission" | "error" | "done"
export type TuiAttentionSound =
| boolean
| {
name?: TuiAttentionSoundName
volume?: number
when?: TuiAttentionWhen
}
export type TuiAttentionNotification =
| boolean
| {
when?: TuiAttentionWhen
}
export type TuiAttentionSoundPack = {
id: string
name?: string
sounds: Partial<Record<TuiAttentionSoundName, string>>
}
export type TuiAttentionSoundPackInfo = {
id: string
name?: string
active: boolean
builtin: boolean
}
export type TuiAttentionSoundboardActivateOptions = {
persist?: boolean
}
export type TuiAttentionSoundboard = {
registerPack(pack: TuiAttentionSoundPack): () => void
activate(id: string, options?: TuiAttentionSoundboardActivateOptions): boolean
current(): string
list(): ReadonlyArray<TuiAttentionSoundPackInfo>
}
export type TuiAttentionNotifyInput = {
title?: string
message: string
notification?: TuiAttentionNotification
sound?: TuiAttentionSound
}
export type TuiAttentionNotifySkipReason =
| "attention_disabled"
| "empty_message"
| "blurred"
| "focused"
| "focus_unknown"
| "renderer_destroyed"
export type TuiAttentionNotifyResult = {
ok: boolean
notification: boolean
sound: boolean
skipped?: TuiAttentionNotifySkipReason
}
export type TuiAttention = {
notify(input: TuiAttentionNotifyInput): Promise<TuiAttentionNotifyResult>
soundboard: TuiAttentionSoundboard
}
export type TuiThemeCurrent = {
readonly primary: RGBA
readonly secondary: RGBA
@@ -331,9 +400,19 @@ type TuiBindingLookupView = {
omit: (name: string, commands: readonly string[]) => Binding<Renderable, KeyEvent>[]
}
type TuiAttentionConfigView = {
enabled: boolean
notifications: boolean
sound: boolean
volume: number
sound_pack: string
sounds: Partial<Record<TuiAttentionSoundName, string>>
}
type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "plugin"> &
NonNullable<PluginConfig["tui"]> & {
leader_timeout: number
attention: TuiAttentionConfigView
plugin_enabled?: Record<string, boolean>
keybinds: TuiBindingLookupView
}
@@ -497,6 +576,7 @@ export type TuiWorkspace = {
export type TuiPluginApi = {
app: TuiApp
attention: TuiAttention
/**
* Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2.
*
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.