Skip to content
Open
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
10 changes: 9 additions & 1 deletion packages/plugin/src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,15 @@ export type TuiSlots = {
}

export type TuiEventBus = {
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => () => void
on: <Type extends Event["type"]>(
type: Type,
handler: (event: Extract<Event, { type: Type }>, metadata: TuiEventMetadata) => void,
) => () => void
}

export type TuiEventMetadata = {
directory: string
workspace: string | undefined
}

export type TuiDispose = () => void | Promise<void>
Expand Down
32 changes: 24 additions & 8 deletions packages/tui/src/feature-plugins/system/notifications.ts
Original file line number Diff line number Diff line change
@@ -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<Event, { type: "session.error" }>["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,
Expand All @@ -32,32 +38,41 @@ const tui: TuiPlugin = async (api) => {
const questions = new Set<string>()
const permissions = new Set<string>()

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)
Expand All @@ -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")
Expand Down
96 changes: 90 additions & 6 deletions packages/tui/test/cli/cmd/tui/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Event["type"], ((event: Event) => void)[]>()
const handlers = new Map<
Event["type"],
((event: Event, metadata: { directory: string; workspace: string | undefined }) => void)[]
>()
const permissions: Record<string, PermissionRequest[]> = {}
const questions: Record<string, QuestionRequest[]> = {}
const session = (id: string, title: string, parentID?: string): Session => ({
id,
title,
Expand All @@ -33,9 +38,18 @@ async function setup() {
},
},
event: {
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => {
on: <Type extends Event["type"]>(
type: Type,
handler: (
event: Extract<Event, { type: Type }>,
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 () => {
Expand All @@ -49,6 +63,8 @@ async function setup() {
state: {
session: {
get: (sessionID: string) => sessions[sessionID],
permission: (sessionID: string) => permissions[sessionID] ?? [],
question: (sessionID: string) => questions[sessionID] ?? [],
},
},
}),
Expand All @@ -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)
},
}
}
Expand Down Expand Up @@ -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()

Expand Down
Loading