Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3212f8c695 | |||
| 643800ef0d | |||
| 4d296b96cf | |||
| 1d21c56e04 | |||
| 5d763d9ee7 | |||
| 9fdc885e1f | |||
| af50244427 | |||
| f6fcee9b9e | |||
| a7b44afdba | |||
| 3a5746ce49 |
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Vendored
+5
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user