FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

feat(tui): session-scoped plugin toasts by nexxeln · Pull Request #49962 · anomalyco/opencode · GitHub

Repository navigation

Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension .ts  (3) .tsx  (2) All 2 file types selected
Viewed files
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Unified
Split
Hide whitespace
Diff view
Unified
Split
Hide whitespace
Next Next commit
feat(tui): session-scoped plugin toasts
  • Loading branch information
nexxeln committed Sep 19, 2026
commit 3e3ce0b3312f5ee89a7eae7c3c744be0d394ceee
6 changes: 6 additions & 0 deletions packages/plugin/src/tui/context.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,12 @@ export interface ToastOptions {
readonly message: string
readonly variant?: ToastVariant
readonly duration?: number
/**
* Scopes the toast to a session. When that session (or another member of its family) is
* open, the toast shows as-is. Otherwise the title defaults to the session title and the
* toast gains an action that navigates to the session.
*/
readonly sessionID?: string
}

export interface Toast {
Expand Down
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,7 @@ export default Plugin.define({
}
errored.add(sessionID)
notify(context, sessionID, event.data.error.message, "error")
const route = context.ui.router.current()
if (route.type === "session" && route.sessionID === sessionID)
context.ui.toast.show({ title: "Session failed", message: event.data.error.message, variant: "error" })
context.ui.toast.show({ sessionID, title: "Session failed", message: event.data.error.message, variant: "error" })
ended(sessionID)
}),
]
Expand Down
23 changes: 22 additions & 1 deletion packages/tui/src/plugin/api.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,28 @@ export function createPluginContext(input: {
const dialogApi = createDialogApi(host.dialog, provide)
const toastApi: Toast = {
show(options) {
host.toast.show({ ...options, variant: options.variant ?? "info" })
const toast = {
title: options.title,
message: options.message,
variant: options.variant ?? "info",
duration: options.duration,
}
const sessionID = options.sessionID
if (sessionID === undefined) {
host.toast.show(toast)
return
}
const route = host.route.data
// Family-scoped so a subagent's toast reads as local while its parent is open, and vice versa.
if (route.type === "session" && host.data.session.root(route.sessionID) === host.data.session.root(sessionID)) {
host.toast.show(toast)
return
}
host.toast.show({
...toast,
title: toast.title ?? host.data.session.get(sessionID)?.title,
action: { label: "Open", run: () => host.route.navigate({ type: "session", sessionID }) },
})
},
}
// Unregistering after deactivation is a no-op: deactivate already resets
Expand Down
12 changes: 8 additions & 4 deletions packages/tui/test/cli/cmd/tui/notifications.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -146,23 +146,27 @@ const permissionNotification: AttentionNotifyOptions = {
}

describe("internal notifications TUI plugin", () => {
test("shows execution failures in the viewed session without needing an assistant message", async () => {
test("shows execution failures as session-scoped toasts without needing an assistant message", async () => {
const harness = await setup()
harness.emit(executionStarted("started"))
harness.emit(executionFailed("failed"))
harness.emit(executionFailed("duplicate"))
expect(harness.toasts).toEqual([{ title: "Session failed", message: "boom", variant: "error" }])
expect(harness.toasts).toEqual([
{ sessionID: "session", title: "Session failed", message: "boom", variant: "error" },
])
harness.emit(executionStarted("retry"))
harness.emit(executionFailed("failed-again"))
expect(harness.toasts).toHaveLength(2)
})

test.each<Route>([{ type: "home" }, { type: "session", sessionID: "other" }])(
"keeps other sessions' failures out of the current composer (%j)",
"leaves routing of other sessions' failures to the session-scoped toast (%j)",
async (route) => {
const harness = await setup(route)
harness.emit(executionFailed("failed"))
expect(harness.toasts).toEqual([])
expect(harness.toasts).toEqual([
{ sessionID: "session", title: "Session failed", message: "boom", variant: "error" },
])
expect(harness.notifications).toHaveLength(1)
},
)
Expand Down
96 changes: 96 additions & 0 deletions packages/tui/test/plugin-toast.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, test } from "bun:test"
import type { Route } from "@opencode/plugin/tui/context"
import { createPluginContext, type Registry, type usePluginHost } from "../src/plugin/api"

type Host = ReturnType<typeof usePluginHost>
type Shown = Parameters<Host["toast"]["show"]>[0]

const sessions: Record<string, { id: string; title?: string; parentID?: string }> = {
parent: { id: "parent", title: "Parent session" },
child: { id: "child", title: "Child session", parentID: "parent" },
other: { id: "other", title: "Other session" },
untitled: { id: "untitled" },
}

const root = (sessionID: string): string => {
const parentID = sessions[sessionID]?.parentID
return parentID ? root(parentID) : sessionID
}

function setup(route: Route) {
const shown: Shown[] = []
const navigated: Route[] = []
const host = {
app: { version: "test", channel: "test" },
client: { api: {} },
keymap: {},
shortcuts: {},
keymapState: {},
sessionTabs: {},
toast: { show: (toast: Shown) => shown.push(toast) },
route: {
get data() {
return route
},
navigate: (destination: Route) => navigated.push(destination),
},
data: { session: { root, get: (sessionID: string) => sessions[sessionID] } },
} as unknown as Host
const registry: Registry = { has: () => false, set() {}, remove() {}, active: () => true }
const context = createPluginContext({ host, id: "test", options: undefined, owned: [], registry })
return { shown, navigated, toast: context.ui.toast }
}

describe("plugin session-scoped toasts", () => {
test("unscoped toasts pass through with the default variant", () => {
const harness = setup({ type: "home" })
harness.toast.show({ message: "hello" })
expect(harness.shown).toEqual([{ title: undefined, message: "hello", variant: "info", duration: undefined }])
})

test("shows as-is while the session is open", () => {
const harness = setup({ type: "session", sessionID: "parent" })
harness.toast.show({ sessionID: "parent", message: "done", variant: "success" })
expect(harness.shown).toEqual([{ title: undefined, message: "done", variant: "success", duration: undefined }])
})

test.each([
["subagent toast while parent is open", "parent", "child"],
["parent toast while subagent is open", "child", "parent"],
])("%s counts as local", (_, routed, target) => {
const harness = setup({ type: "session", sessionID: routed })
harness.toast.show({ sessionID: target, message: "done" })
expect(harness.shown[0]).not.toHaveProperty("action")
})

test.each<Route>([{ type: "home" }, { type: "session", sessionID: "parent" }])(
"adds the session title and an Open action when the session is not open (%j)",
(route) => {
const harness = setup(route)
harness.toast.show({ sessionID: "other", message: "done", variant: "success", duration: 1000 })
expect(harness.shown).toHaveLength(1)
expect(harness.shown[0]).toMatchObject({
title: "Other session",
message: "done",
variant: "success",
duration: 1000,
action: { label: "Open" },
})
harness.shown[0].action?.run()
expect(harness.navigated).toEqual([{ type: "session", sessionID: "other" }])
},
)

test("keeps an explicit title when the session is not open", () => {
const harness = setup({ type: "home" })
harness.toast.show({ sessionID: "other", title: "Build finished", message: "done" })
expect(harness.shown[0].title).toBe("Build finished")
})

test("leaves the title empty for an untitled session that is not open", () => {
const harness = setup({ type: "home" })
harness.toast.show({ sessionID: "untitled", message: "done" })
expect(harness.shown[0].title).toBeUndefined()
expect(harness.shown[0].action?.label).toBe("Open")
})
})
Loading

Back | FazBrowse Home | New Git URL