From af50244427ee6e6699dd443abf06a4bbdd983e95 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Mon, 11 May 2026 23:46:35 +0200 Subject: [PATCH] attention control --- packages/opencode/specs/tui-plugins.md | 5 +- .../opencode/src/cli/cmd/tui/attention.ts | 11 ++- .../test/cli/cmd/tui/attention.test.ts | 93 ++++++++++++++++--- packages/plugin/src/tui.ts | 3 +- 4 files changed, 95 insertions(+), 17 deletions(-) diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index 66f5b5405d..4311e05fce 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -259,8 +259,9 @@ Top-level API groups exposed to `tui(api, options, meta)`: ### 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. +- `message` is required; `title` defaults to `"opencode"`; `when` defaults to `"always"`; `sound` defaults to `false`. +- `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. - 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. diff --git a/packages/opencode/src/cli/cmd/tui/attention.ts b/packages/opencode/src/cli/cmd/tui/attention.ts index 1078adf092..623e44306c 100644 --- a/packages/opencode/src/cli/cmd/tui/attention.ts +++ b/packages/opencode/src/cli/cmd/tui/attention.ts @@ -74,6 +74,13 @@ function soundVolume(input: TuiAttentionNotifyInput, config: Pick @@ -144,8 +151,8 @@ export function createTuiAttention(input: { 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 skip = focusSkip(request.when, focus) + if (skip) return skipped(skip) const notification = input.config.attention.notifications ? (() => { diff --git a/packages/opencode/test/cli/cmd/tui/attention.test.ts b/packages/opencode/test/cli/cmd/tui/attention.test.ts index 5821fae80a..0d7b3a127f 100644 --- a/packages/opencode/test/cli/cmd/tui/attention.test.ts +++ b/packages/opencode/test/cli/cmd/tui/attention.test.ts @@ -122,33 +122,86 @@ function config(attention: Partial = {}): Attentio } describe("createTuiAttention", () => { - test("skips blurred-only requests until focus is known blurred", async () => { + test("defaults to always and delivers before focus is known", 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: true, + notification: true, + sound: true, + }) + expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello" }]) + 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: true, when: "blurred" })).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({ + expect(await attention.notify({ message: "focused", sound: true, when: "blurred" })).toEqual({ ok: false, notification: false, sound: false, skipped: "focused", }) - expect(renderer.notifications).toHaveLength(0) + renderer.emit("blur") + expect(await attention.notify({ message: "blurred", sound: true, 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", when: "focused" })).toEqual({ + ok: false, + notification: false, + sound: false, + skipped: "focus_unknown", + }) + renderer.emit("blur") + expect(await attention.notify({ message: "blurred", when: "focused" })).toEqual({ + ok: false, + notification: false, + sound: false, + skipped: "blurred", + }) + renderer.emit("focus") + expect(await attention.notify({ message: "focused", when: "focused" })).toEqual({ + ok: true, + notification: true, + sound: false, + }) + expect(renderer.notifications).toEqual([{ title: "opencode", message: "focused" }]) + }) + + test("always requests still deliver 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: true })).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 () => { @@ -164,6 +217,22 @@ describe("createTuiAttention", () => { 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: true, when: "blurred" })).toEqual({ + ok: false, + notification: false, + sound: false, + skipped: "focused", + }) + expect(renderer.notifications).toHaveLength(0) + expect(audio.createCalls).toBe(0) + }) + test("skips empty messages and disabled attention", async () => { const empty = new FakeRenderer() empty.emit("blur") @@ -222,7 +291,7 @@ describe("createTuiAttention", () => { const audio = new FakeAudio() const attention = createTuiAttention({ renderer, config: config(), audio }) - await attention.notify({ message: "unknown", sound: true }) + await attention.notify({ message: "unknown", sound: true, when: "blurred" }) expect(audio.createCalls).toBe(0) renderer.emit("blur") diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index d0652f63b0..b057adf890 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -225,7 +225,7 @@ export type TuiToast = { duration?: number } -export type TuiAttentionWhen = "blurred" +export type TuiAttentionWhen = "always" | "focused" | "blurred" export type TuiAttentionSound = | boolean @@ -244,6 +244,7 @@ export type TuiAttentionNotifyInput = { export type TuiAttentionNotifySkipReason = | "attention_disabled" | "empty_message" + | "blurred" | "focused" | "focus_unknown" | "renderer_destroyed"