Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
390 changes: 28 additions & 362 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-x64", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid"]

[test]
root = "./do-not-run-tests-from-root"
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.8",
"@opentui/keymap": "0.2.8",
"@opentui/solid": "0.2.8",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
Expand Down Expand Up @@ -128,6 +128,9 @@
"electron"
],
"overrides": {
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:"
},
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
34 changes: 34 additions & 0 deletions packages/opencode/specs/tui-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
```
Expand All @@ -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` defaults to `false`; when `false`, it disables all `api.attention.notify(...)` delivery.
- `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`, `done`, or `subagent_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.
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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"`, `"done"`, and `"subagent_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`.
Expand Down
13 changes: 13 additions & 0 deletions packages/opencode/specs/v2/notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# TUI Notifications Default

Problem:

- v1 defaults `attention.enabled` to `false`
- users can opt in with `attention.enabled = true`
- v2 should make core TUI notifications a default behavior

## v2 Target

Flip `attention.enabled` to `true` by default in v2.

Keep `attention.enabled = false` as the explicit opt-out.
5 changes: 5 additions & 0 deletions packages/opencode/src/audio.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@op
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import * as Clipboard from "@tui/util/clipboard"
import * as Selection from "@tui/util/selection"
import * as TuiAudio from "@tui/util/audio"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import {
Expand Down Expand Up @@ -63,6 +64,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"
Expand Down Expand Up @@ -176,10 +178,10 @@ export function tui(input: {
unguard?.()
resolve()
}

const onBeforeExit = async () => {
offKeymap()
await TuiPluginRuntime.dispose()
TuiAudio.dispose()
}

const renderer = await createCliRenderer(rendererConfig(input.config))
Expand Down Expand Up @@ -283,6 +285,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,
Expand All @@ -298,11 +301,13 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
theme: themeState,
toast,
renderer,
attention,
})
const [ready, setReady] = createSignal(false)
TuiPluginRuntime.init({
api,
config: tuiConfig,
dispose: () => attention.dispose(),
})
.catch((error) => {
console.error("Failed to load TUI plugins", error)
Expand All @@ -320,7 +325,10 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
},
{ priority: 1 },
)
onCleanup(offSelectionKeys)
onCleanup(() => {
offSelectionKeys()
attention.dispose()
})

// Wire up console copy-to-clipboard via opentui's onCopySelection callback
renderer.console.onCopySelection = async (text: string) => {
Expand Down
Loading
Loading