Skip to content

Commit 6ff93c2

Browse files
committed
notifications plugin
1 parent bb9d43d commit 6ff93c2

3 files changed

Lines changed: 278 additions & 0 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type { Event } from "@opencode-ai/sdk/v2"
2+
import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
3+
import type { InternalTuiPlugin } from "../../plugin/internal"
4+
5+
const id = "internal:notifications"
6+
7+
type SessionError = Extract<Event, { type: "session.error" }>["properties"]["error"]
8+
9+
function notify(api: TuiPluginApi, message: string, sound: TuiAttentionSoundName) {
10+
void api.attention.notify({
11+
message,
12+
notification: { when: "blurred" },
13+
sound: { name: sound, when: "always" },
14+
})
15+
}
16+
17+
function errorDataMessage(error: SessionError) {
18+
const data = error?.data
19+
if (!data || typeof data !== "object" || !("message" in data)) return ""
20+
return typeof data.message === "string" ? data.message : ""
21+
}
22+
23+
function sessionErrorMessage(error: SessionError) {
24+
if (error?.name === "MessageAbortedError") return "Session aborted"
25+
if (errorDataMessage(error) === "SSE read timed out") return "Model stopped responding"
26+
return "Session error"
27+
}
28+
29+
const tui: TuiPlugin = async (api) => {
30+
const active = new Set<string>()
31+
const errored = new Set<string>()
32+
const questions = new Set<string>()
33+
const permissions = new Set<string>()
34+
35+
api.event.on("question.asked", (event) => {
36+
if (questions.has(event.properties.id)) return
37+
questions.add(event.properties.id)
38+
notify(api, "Question needs input", "question")
39+
})
40+
41+
api.event.on("question.replied", (event) => {
42+
questions.delete(event.properties.requestID)
43+
})
44+
45+
api.event.on("question.rejected", (event) => {
46+
questions.delete(event.properties.requestID)
47+
})
48+
49+
api.event.on("permission.asked", (event) => {
50+
if (permissions.has(event.properties.id)) return
51+
permissions.add(event.properties.id)
52+
notify(api, "Permission needs input", "permission")
53+
})
54+
55+
api.event.on("permission.replied", (event) => {
56+
permissions.delete(event.properties.requestID)
57+
})
58+
59+
api.event.on("session.status", (event) => {
60+
const sessionID = event.properties.sessionID
61+
if (event.properties.status.type === "busy" || event.properties.status.type === "retry") {
62+
active.add(sessionID)
63+
errored.delete(sessionID)
64+
return
65+
}
66+
67+
if (event.properties.status.type !== "idle") return
68+
if (!active.has(sessionID)) return
69+
active.delete(sessionID)
70+
71+
if (errored.has(sessionID)) {
72+
errored.delete(sessionID)
73+
return
74+
}
75+
76+
notify(api, "Session done", "done")
77+
})
78+
79+
api.event.on("session.error", (event) => {
80+
const sessionID = event.properties.sessionID
81+
if (!sessionID) return
82+
if (!active.has(sessionID)) return
83+
errored.add(sessionID)
84+
notify(api, sessionErrorMessage(event.properties.error), "error")
85+
})
86+
}
87+
88+
const plugin: InternalTuiPlugin = {
89+
id,
90+
tui,
91+
}
92+
93+
export default plugin

packages/opencode/src/cli/cmd/tui/plugin/internal.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import SidebarTodo from "../feature-plugins/sidebar/todo"
77
import SidebarFiles from "../feature-plugins/sidebar/files"
88
import SidebarFooter from "../feature-plugins/sidebar/footer"
99
import PluginManager from "../feature-plugins/system/plugins"
10+
import Notifications from "../feature-plugins/system/notifications"
1011
import SessionV2Debug from "../feature-plugins/system/session-v2"
1112
import WhichKey from "../feature-plugins/system/which-key"
1213
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
@@ -27,6 +28,7 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
2728
SidebarTodo,
2829
SidebarFiles,
2930
SidebarFooter,
31+
Notifications,
3032
PluginManager,
3133
WhichKey,
3234
...(Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM ? [SessionV2Debug] : []),
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { describe, expect, test } from "bun:test"
2+
import Notifications from "@/cli/cmd/tui/feature-plugins/system/notifications"
3+
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
4+
import type { TuiAttentionNotifyInput, TuiPluginApi } from "@opencode-ai/plugin/tui"
5+
6+
class Harness {
7+
notifications: TuiAttentionNotifyInput[] = []
8+
private handlers = new Map<Event["type"], ((event: Event) => void)[]>()
9+
10+
api() {
11+
return {
12+
attention: {
13+
notify: async (input: TuiAttentionNotifyInput) => {
14+
this.notifications.push(input)
15+
return { ok: true, notification: true, sound: true }
16+
},
17+
soundboard: {
18+
registerPack: () => () => {},
19+
activate: () => false,
20+
current: () => "opencode.default",
21+
list: () => [],
22+
},
23+
},
24+
event: {
25+
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => {
26+
const list = this.handlers.get(type) ?? []
27+
const wrapped = handler as (event: Event) => void
28+
list.push(wrapped)
29+
this.handlers.set(type, list)
30+
return () => {
31+
this.handlers.set(
32+
type,
33+
(this.handlers.get(type) ?? []).filter((item) => item !== wrapped),
34+
)
35+
}
36+
},
37+
},
38+
} as unknown as TuiPluginApi
39+
}
40+
41+
emit(event: Event) {
42+
for (const handler of this.handlers.get(event.type) ?? []) handler(event)
43+
}
44+
}
45+
46+
function question(id: string): QuestionRequest {
47+
return {
48+
id,
49+
sessionID: "session",
50+
questions: [],
51+
}
52+
}
53+
54+
function permission(id: string): PermissionRequest {
55+
return {
56+
id,
57+
sessionID: "session",
58+
permission: "edit",
59+
patterns: [],
60+
metadata: {},
61+
always: [],
62+
}
63+
}
64+
65+
async function setup() {
66+
const harness = new Harness()
67+
await Notifications.tui(harness.api(), undefined, {} as never)
68+
return harness
69+
}
70+
71+
const questionNotification: TuiAttentionNotifyInput = {
72+
message: "Question needs input",
73+
notification: { when: "blurred" },
74+
sound: { name: "question", when: "always" },
75+
}
76+
77+
const permissionNotification: TuiAttentionNotifyInput = {
78+
message: "Permission needs input",
79+
notification: { when: "blurred" },
80+
sound: { name: "permission", when: "always" },
81+
}
82+
83+
describe("internal notifications TUI plugin", () => {
84+
test("notifies for question and permission requests with blurred notifications and always-on sounds", async () => {
85+
const harness = await setup()
86+
87+
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
88+
harness.emit({ id: "event-2", type: "permission.asked", properties: permission("permission-1") })
89+
90+
expect(harness.notifications).toEqual([questionNotification, permissionNotification])
91+
})
92+
93+
test("dedupes pending questions and permissions until they are resolved", async () => {
94+
const harness = await setup()
95+
96+
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
97+
harness.emit({ id: "event-2", type: "question.asked", properties: question("question-1") })
98+
harness.emit({ id: "event-3", type: "question.replied", properties: { sessionID: "session", requestID: "question-1", answers: [] } })
99+
harness.emit({ id: "event-4", type: "question.asked", properties: question("question-1") })
100+
101+
harness.emit({ id: "event-5", type: "permission.asked", properties: permission("permission-1") })
102+
harness.emit({ id: "event-6", type: "permission.asked", properties: permission("permission-1") })
103+
harness.emit({
104+
id: "event-7",
105+
type: "permission.replied",
106+
properties: { sessionID: "session", requestID: "permission-1", reply: "once" },
107+
})
108+
harness.emit({ id: "event-8", type: "permission.asked", properties: permission("permission-1") })
109+
110+
expect(harness.notifications).toEqual([
111+
questionNotification,
112+
questionNotification,
113+
permissionNotification,
114+
permissionNotification,
115+
])
116+
})
117+
118+
test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
119+
const harness = await setup()
120+
121+
harness.emit({ id: "event-1", type: "session.status", properties: { sessionID: "session", status: { type: "idle" } } })
122+
harness.emit({ id: "event-2", type: "session.status", properties: { sessionID: "session", status: { type: "busy" } } })
123+
harness.emit({ id: "event-3", type: "session.status", properties: { sessionID: "session", status: { type: "idle" } } })
124+
125+
expect(harness.notifications).toEqual([
126+
{
127+
message: "Session done",
128+
notification: { when: "blurred" },
129+
sound: { name: "done", when: "always" },
130+
},
131+
])
132+
})
133+
134+
test("notifies session errors once and suppresses the following idle done notification", async () => {
135+
const harness = await setup()
136+
137+
harness.emit({ id: "event-1", type: "session.status", properties: { sessionID: "session", status: { type: "busy" } } })
138+
harness.emit({
139+
id: "event-2",
140+
type: "session.error",
141+
properties: { sessionID: "session", error: { name: "UnknownError", data: { message: "boom" } } },
142+
})
143+
harness.emit({ id: "event-3", type: "session.status", properties: { sessionID: "session", status: { type: "idle" } } })
144+
145+
expect(harness.notifications).toEqual([
146+
{
147+
message: "Session error",
148+
notification: { when: "blurred" },
149+
sound: { name: "error", when: "always" },
150+
},
151+
])
152+
})
153+
154+
test("special-cases aborts and model response timeouts", async () => {
155+
const harness = await setup()
156+
157+
harness.emit({ id: "event-1", type: "session.status", properties: { sessionID: "abort", status: { type: "busy" } } })
158+
harness.emit({
159+
id: "event-2",
160+
type: "session.error",
161+
properties: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
162+
})
163+
harness.emit({ id: "event-3", type: "session.status", properties: { sessionID: "timeout", status: { type: "busy" } } })
164+
harness.emit({
165+
id: "event-4",
166+
type: "session.error",
167+
properties: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
168+
})
169+
170+
expect(harness.notifications).toEqual([
171+
{
172+
message: "Session aborted",
173+
notification: { when: "blurred" },
174+
sound: { name: "error", when: "always" },
175+
},
176+
{
177+
message: "Model stopped responding",
178+
notification: { when: "blurred" },
179+
sound: { name: "error", when: "always" },
180+
},
181+
])
182+
})
183+
})

0 commit comments

Comments
 (0)