From 97c49512ac89c7f9aeecfcb9b47a54832b7bab6d Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Mon, 10 Aug 2026 20:21:46 +0000 Subject: [PATCH] fix(tui): scope attention notifications --- packages/plugin/src/tui.ts | 10 +- .../feature-plugins/system/notifications.ts | 32 +++++-- .../test/cli/cmd/tui/notifications.test.ts | 96 +++++++++++++++++-- 3 files changed, 123 insertions(+), 15 deletions(-) diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 67812169475b..a3b56de659c8 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -517,7 +517,15 @@ export type TuiSlots = { } export type TuiEventBus = { - on: (type: Type, handler: (event: Extract) => void) => () => void + on: ( + type: Type, + handler: (event: Extract, metadata: TuiEventMetadata) => void, + ) => () => void +} + +export type TuiEventMetadata = { + directory: string + workspace: string | undefined } export type TuiDispose = () => void | Promise diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index fbadacce929d..1a79e9f6ee27 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -1,13 +1,19 @@ import type { Event } from "@opencode-ai/sdk/v2" -import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { TuiAttentionSoundName, TuiEventMetadata, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "../builtins" const id = "internal:notifications" type SessionError = Extract["properties"]["error"] +function matchesSession(api: TuiPluginApi, sessionID: string, metadata: TuiEventMetadata) { + const session = api.state.session.get(sessionID) + return session?.directory === metadata.directory && session.workspaceID === metadata.workspace +} + function notify(api: TuiPluginApi, sessionID: string | undefined, message: string, sound: TuiAttentionSoundName) { const session = sessionID ? api.state.session.get(sessionID) : undefined + if (!session) return const isSubagent = session?.parentID !== undefined void api.attention.notify({ title: session?.title, @@ -32,32 +38,41 @@ const tui: TuiPlugin = async (api) => { const questions = new Set() const permissions = new Set() - api.event.on("question.asked", (event) => { + api.event.on("question.asked", (event, metadata) => { + if (!matchesSession(api, event.properties.sessionID, metadata)) return + if (!api.state.session.question(event.properties.sessionID).some((item) => item.id === event.properties.id)) return if (questions.has(event.properties.id)) return questions.add(event.properties.id) notify(api, event.properties.sessionID, "Question needs input", "question") }) - api.event.on("question.replied", (event) => { + api.event.on("question.replied", (event, metadata) => { + if (!matchesSession(api, event.properties.sessionID, metadata)) return questions.delete(event.properties.requestID) }) - api.event.on("question.rejected", (event) => { + api.event.on("question.rejected", (event, metadata) => { + if (!matchesSession(api, event.properties.sessionID, metadata)) return questions.delete(event.properties.requestID) }) - api.event.on("permission.asked", (event) => { + api.event.on("permission.asked", (event, metadata) => { + if (!matchesSession(api, event.properties.sessionID, metadata)) return + if (!api.state.session.permission(event.properties.sessionID).some((item) => item.id === event.properties.id)) + return if (permissions.has(event.properties.id)) return permissions.add(event.properties.id) notify(api, event.properties.sessionID, "Permission needs input", "permission") }) - api.event.on("permission.replied", (event) => { + api.event.on("permission.replied", (event, metadata) => { + if (!matchesSession(api, event.properties.sessionID, metadata)) return permissions.delete(event.properties.requestID) }) - api.event.on("session.status", (event) => { + api.event.on("session.status", (event, metadata) => { const sessionID = event.properties.sessionID + if (!matchesSession(api, sessionID, metadata)) return if (event.properties.status.type === "busy" || event.properties.status.type === "retry") { active.add(sessionID) errored.delete(sessionID) @@ -77,9 +92,10 @@ const tui: TuiPlugin = async (api) => { notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done") }) - api.event.on("session.error", (event) => { + api.event.on("session.error", (event, metadata) => { const sessionID = event.properties.sessionID if (!sessionID) return + if (!matchesSession(api, sessionID, metadata)) return if (!active.has(sessionID)) return errored.add(sessionID) notify(api, sessionID, sessionErrorMessage(event.properties.error), "error") diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index 618a225df2e0..47401a4931cb 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -4,9 +4,14 @@ import type { Event, PermissionRequest, QuestionRequest, Session } from "@openco import type { TuiAttentionNotifyInput } from "@opencode-ai/plugin/tui" import { createTuiPluginApi } from "../../../fixture/tui-plugin" -async function setup() { +async function setup(input: { auto?: boolean } = {}) { const notifications: TuiAttentionNotifyInput[] = [] - const handlers = new Map void)[]>() + const handlers = new Map< + Event["type"], + ((event: Event, metadata: { directory: string; workspace: string | undefined }) => void)[] + >() + const permissions: Record = {} + const questions: Record = {} const session = (id: string, title: string, parentID?: string): Session => ({ id, title, @@ -33,9 +38,18 @@ async function setup() { }, }, event: { - on: (type: Type, handler: (event: Extract) => void) => { + on: ( + type: Type, + handler: ( + event: Extract, + metadata: { directory: string; workspace: string | undefined }, + ) => void, + ) => { const list = handlers.get(type) ?? [] - const wrapped = handler as (event: Event) => void + const wrapped = handler as ( + event: Event, + metadata: { directory: string; workspace: string | undefined }, + ) => void list.push(wrapped) handlers.set(type, list) return () => { @@ -49,6 +63,8 @@ async function setup() { state: { session: { get: (sessionID: string) => sessions[sessionID], + permission: (sessionID: string) => permissions[sessionID] ?? [], + question: (sessionID: string) => questions[sessionID] ?? [], }, }, }), @@ -58,8 +74,30 @@ async function setup() { return { notifications, - emit(event: Event) { - for (const handler of handlers.get(event.type) ?? []) handler(event) + emit(event: Event, metadata = { directory: "/workspace", workspace: undefined as string | undefined }) { + if (event.type === "permission.asked" && !input.auto) { + permissions[event.properties.sessionID] = [ + ...(permissions[event.properties.sessionID] ?? []).filter((item) => item.id !== event.properties.id), + event.properties, + ] + } + if (event.type === "permission.replied") { + permissions[event.properties.sessionID] = (permissions[event.properties.sessionID] ?? []).filter( + (item) => item.id !== event.properties.requestID, + ) + } + if (event.type === "question.asked") { + questions[event.properties.sessionID] = [ + ...(questions[event.properties.sessionID] ?? []).filter((item) => item.id !== event.properties.id), + event.properties, + ] + } + if (event.type === "question.replied" || event.type === "question.rejected") { + questions[event.properties.sessionID] = (questions[event.properties.sessionID] ?? []).filter( + (item) => item.id !== event.properties.requestID, + ) + } + for (const handler of handlers.get(event.type) ?? []) handler(event, metadata) }, } } @@ -136,6 +174,52 @@ describe("internal notifications TUI plugin", () => { ]) }) + test("suppresses auto-approved permission requests", async () => { + const harness = await setup({ auto: true }) + + harness.emit({ id: "event-1", type: "permission.asked", properties: permission("permission-1") }) + + expect(harness.notifications).toEqual([]) + }) + + test("ignores events outside the session location", async () => { + const harness = await setup() + const foreign = { directory: "/other", workspace: undefined } + + harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") }, foreign) + harness.emit({ id: "event-2", type: "permission.asked", properties: permission("permission-1") }, foreign) + harness.emit( + { id: "event-2b", type: "permission.asked", properties: permission("permission-2") }, + { directory: "/workspace", workspace: "other-workspace" }, + ) + harness.emit( + { + id: "event-3", + type: "session.status", + properties: { sessionID: "session", status: { type: "busy" } }, + }, + foreign, + ) + harness.emit( + { + id: "event-4", + type: "session.error", + properties: { sessionID: "session", error: { name: "UnknownError", data: { message: "boom" } } }, + }, + foreign, + ) + harness.emit( + { + id: "event-5", + type: "session.status", + properties: { sessionID: "session", status: { type: "idle" } }, + }, + foreign, + ) + + expect(harness.notifications).toEqual([]) + }) + test("notifies when an active session becomes idle and suppresses no-op idle", async () => { const harness = await setup()