diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index c1a9b271c1..66f5b5405d 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -29,6 +29,12 @@ 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 } } ``` @@ -45,6 +51,9 @@ 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`. - `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 `` shortcuts. @@ -212,6 +221,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 +256,16 @@ 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, sound?, when? })` requests user attention while keeping terminal focus, notifications, and audio owned by the host. +- `message` is required; `title` defaults to `"opencode"`; `when` defaults to `"blurred"`; `sound` defaults to `false`. +- `when: "blurred"` is the only supported mode. Calls are skipped while the terminal is focused or before any focus/blur event has been observed. +- The host strips ANSI/control characters and collapses newlines before sending text to the terminal notification API. +- `sound: true` plays the built-in attention sound at `attention.volume`; `sound: { volume }` overrides it for that call; `sound: { enabled: false }` disables sound for that call. +- 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`. diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index d7f2cd14b0..4d24d0c2ad 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -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, type TuiAttentionHost } 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" @@ -180,9 +181,11 @@ export function tui(input: { const onBeforeExit = async () => { offKeymap() await TuiPluginRuntime.dispose() + attention.dispose() } const renderer = await createCliRenderer(rendererConfig(input.config)) + const attention = createTuiAttention({ renderer, config: input.config }) // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash. void renderer.getPalette({ size: 16 }).catch(() => undefined) const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" @@ -232,7 +235,7 @@ export function tui(input: { - + @@ -259,7 +262,7 @@ export function tui(input: { }) } -function App(props: { onSnapshot?: () => Promise }) { +function App(props: { onSnapshot?: () => Promise; attention: TuiAttentionHost }) { const tuiConfig = useTuiConfig() const route = useRoute() const dimensions = useTerminalDimensions() @@ -298,6 +301,7 @@ function App(props: { onSnapshot?: () => Promise }) { theme: themeState, toast, renderer, + attention: props.attention, }) const [ready, setReady] = createSignal(false) TuiPluginRuntime.init({ diff --git a/packages/opencode/src/cli/cmd/tui/attention.ts b/packages/opencode/src/cli/cmd/tui/attention.ts new file mode 100644 index 0000000000..1078adf092 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/attention.ts @@ -0,0 +1,187 @@ +import { Audio, type AudioErrorContext, type AudioSound } from "@opentui/core" +import type { + TuiAttention, + TuiAttentionNotifyInput, + TuiAttentionNotifyResult, + TuiAttentionNotifySkipReason, +} from "@opencode-ai/plugin/tui" +import stripAnsi from "strip-ansi" +import type { TuiConfig } from "./config/tui" +import attentionSoundPath from "./asset/pulse-a.wav" 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(): Promise +} + +export type TuiAttentionHost = TuiAttention & { + dispose(): void +} + +const log = Log.create({ service: "tui.attention" }) + +const DEFAULT_TITLE = "opencode" +const TITLE_LIMIT = 80 +const MESSAGE_LIMIT = 240 + +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) { + if (!config.attention.sound) return + if (input.sound === undefined || input.sound === false) return + if (input.sound === true) return clampVolume(config.attention.volume) + if (input.sound.enabled === false) return + return clampVolume(input.sound.volume ?? config.attention.volume) +} + +export function createTuiAttention(input: { + renderer: AttentionRenderer + config: Pick + audio?: AttentionAudio +}): TuiAttentionHost { + let focus: FocusState = "unknown" + let disposed = false + let audio: AttentionAudioEngine | undefined + let sound: AudioSound | null | undefined + let soundTask: Promise | undefined + + 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: () => Bun.file(attentionSoundPath).bytes(), + } satisfies AttentionAudio) + + const onFocus = () => { + focus = "focused" + } + const onBlur = () => { + focus = "blurred" + } + + input.renderer.on("focus", onFocus) + input.renderer.on("blur", onBlur) + + async function loadSound() { + if (!audio) return null + if (sound !== undefined) return sound + soundTask ??= audioInput + .bytes() + .then((bytes) => audio?.loadSound(bytes) ?? null) + .catch((error) => { + log.debug("failed to load attention sound", { error }) + return null + }) + sound = await soundTask + return sound + } + + async function playSound(volume: number) { + try { + audio ??= audioInput.create() + if (!audio.isStarted() && !audio.start()) return false + const current = await loadSound() + if (current == null) return false + return audio.play(current, { volume }) != null + } 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") + + if (focus === "focused") return skipped("focused") + if (focus === "unknown") return skipped("focus_unknown") + + const notification = input.config.attention.notifications + ? (() => { + 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 sound = volume === undefined ? false : await playSound(volume) + + return { + ok: notification || sound, + notification, + sound, + } + } catch (error) { + log.debug("failed to handle attention notification", { error }) + return { + ok: false, + notification: false, + sound: false, + } + } + }, + dispose() { + disposed = true + input.renderer.off("focus", onFocus) + input.renderer.off("blur", onBlur) + audio?.dispose() + audio = undefined + sound = undefined + soundTask = undefined + }, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 80765da3c7..719ae07964 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -17,6 +17,13 @@ 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))), +}).annotate({ description: "Attention notification and sound settings" }) + export const TuiInfo = Schema.Struct({ $schema: Schema.optional(Schema.String), theme: Schema.optional(Schema.String), @@ -24,6 +31,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", }), diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index e53e20d343..955f9b21c7 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -33,7 +33,13 @@ type Acc = { plugin_origins: ConfigPlugin.Origin[] } -export type Resolved = Omit & { +export type Resolved = Omit & { + attention: { + enabled: boolean + notifications: boolean + sound: boolean + volume: number + } keybinds: TuiKeybind.BindingLookupView leader_timeout: number // Internal resolved plugin list used by runtime loading. @@ -197,6 +203,12 @@ 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, + }, keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), { commandMap: TuiKeybind.CommandMap, bindingDefaults: TuiKeybind.bindingDefaults(), diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 54059f4a2d..99ed5b81ec 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -40,6 +40,7 @@ type Input = { theme: ReturnType toast: ReturnType 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: { diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index dad4595e7f..901ee36ff8 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -576,6 +576,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop return { app: api.app, + attention: api.attention, // Keep deprecated `api.command` working for v1 plugins; remove in v2. command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds), keys: api.keys, diff --git a/packages/opencode/test/cli/cmd/tui/attention.test.ts b/packages/opencode/test/cli/cmd/tui/attention.test.ts new file mode 100644 index 0000000000..5821fae80a --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/attention.test.ts @@ -0,0 +1,320 @@ +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 + +class FakeRenderer { + isDestroyed = false + notificationResult = true + notificationThrows = false + notifications: { message: string; title: string | undefined }[] = [] + listeners: Record 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 + rejectBytes = false + + create() { + this.createCalls += 1 + return this.engine + } + + async bytes() { + this.bytesCalls += 1 + if (this.rejectBytes) throw new Error("decode failed") + return new Uint8Array([1, 2, 3]) + } +} + +function config(attention: Partial = {}): AttentionConfig { + return { + attention: { + enabled: true, + notifications: true, + sound: true, + volume: 0.4, + ...attention, + }, + } +} + +describe("createTuiAttention", () => { + test("skips blurred-only requests until focus is known blurred", async () => { + const renderer = new FakeRenderer() + const audio = new FakeAudio() + const attention = createTuiAttention({ renderer, config: config(), audio }) + + expect(await attention.notify({ message: "hello", sound: true })).toEqual({ + ok: false, + notification: false, + sound: false, + skipped: "focus_unknown", + }) + expect(renderer.notifications).toHaveLength(0) + expect(audio.createCalls).toBe(0) + }) + + test("skips focused requests", async () => { + const renderer = new FakeRenderer() + const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudio() }) + renderer.emit("focus") + + expect(await attention.notify({ message: "hello", sound: true })).toEqual({ + ok: false, + notification: false, + sound: false, + skipped: "focused", + }) + expect(renderer.notifications).toHaveLength(0) + }) + + 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" })).toEqual({ + ok: true, + notification: true, + sound: false, + }) + expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello" }]) + }) + + 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: true }) + 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("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", + }) + }) +}) diff --git a/packages/opencode/test/cli/run/runtime.boot.test.ts b/packages/opencode/test/cli/run/runtime.boot.test.ts index e2569b0ac6..3d796c564d 100644 --- a/packages/opencode/test/cli/run/runtime.boot.test.ts +++ b/packages/opencode/test/cli/run/runtime.boot.test.ts @@ -93,6 +93,12 @@ function config(input?: { ...(bind?.inputNewline && { input_newline: bind.inputNewline }), }) return { + attention: { + enabled: true, + notifications: true, + sound: true, + volume: 0.4, + }, diff_style: input?.diff_style, keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), { commandMap: TuiKeybind.CommandMap, diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index db04568573..c6c9044709 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -142,6 +142,32 @@ 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, + }) + + 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 } }, null, 2), + ) + }, + }) + + expect((await getTuiConfig(overridden.path)).attention).toEqual({ + enabled: false, + notifications: false, + sound: false, + volume: 0.7, + }) +}) + test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index 62a3ae6e6b..2994773072 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -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 @@ -183,6 +184,17 @@ 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, + } + }, + }, keys: { formatSequence: () => "", formatBindings: () => undefined, diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts index 64537b6c50..3f928ef8c0 100644 --- a/packages/opencode/test/fixture/tui-runtime.ts +++ b/packages/opencode/test/fixture/tui-runtime.ts @@ -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] -type ResolvedInput = Omit & { +type ResolvedInput = Omit & { + attention?: Partial keybinds?: Partial leader_timeout?: number } @@ -22,6 +23,13 @@ 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, + ...input.attention, + }, keybinds: createTuiResolvedKeybinds(keybinds), leader_timeout: input.leader_timeout ?? 2000, } diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 851b0476e5..d0652f63b0 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -225,6 +225,40 @@ export type TuiToast = { duration?: number } +export type TuiAttentionWhen = "blurred" + +export type TuiAttentionSound = + | boolean + | { + enabled?: boolean + volume?: number + } + +export type TuiAttentionNotifyInput = { + title?: string + message: string + sound?: TuiAttentionSound + when?: TuiAttentionWhen +} + +export type TuiAttentionNotifySkipReason = + | "attention_disabled" + | "empty_message" + | "focused" + | "focus_unknown" + | "renderer_destroyed" + +export type TuiAttentionNotifyResult = { + ok: boolean + notification: boolean + sound: boolean + skipped?: TuiAttentionNotifySkipReason +} + +export type TuiAttention = { + notify(input: TuiAttentionNotifyInput): Promise +} + export type TuiThemeCurrent = { readonly primary: RGBA readonly secondary: RGBA @@ -331,9 +365,17 @@ type TuiBindingLookupView = { omit: (name: string, commands: readonly string[]) => Binding[] } +type TuiAttentionConfigView = { + enabled: boolean + notifications: boolean + sound: boolean + volume: number +} + type TuiConfigView = Pick & NonNullable & { leader_timeout: number + attention: TuiAttentionConfigView plugin_enabled?: Record keybinds: TuiBindingLookupView } @@ -497,6 +539,7 @@ export type TuiWorkspace = { export type TuiPluginApi = { app: TuiApp + attention: TuiAttention /** * Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2. *