diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 93d1fc19ae2b..c855d254857b 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -59,6 +59,7 @@ import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" import { TuiConfig } from "@/config/tui" import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" import { FormatError, FormatUnknownError } from "@/cli/error" +import { Notification } from "@/notification" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { // can't set raw mode if not a TTY @@ -816,6 +817,15 @@ function App(props: { onSnapshot?: () => Promise }) { } }) + sdk.event.on("session.idle", async (evt) => { + if (tuiConfig.notifications === false) return + const focused = await Notification.terminalIsFocused().catch(() => false) + if (focused) return + + const sessionID = evt.properties.sessionID + Notification.show("opencode", `${sessionID} completed`).catch(() => {}) + }) + sdk.event.on("session.error", (evt) => { const error = evt.properties.error if (error && typeof error === "object" && error.name === "MessageAbortedError") return @@ -826,6 +836,12 @@ function App(props: { onSnapshot?: () => Promise }) { message, duration: 5000, }) + + void Notification.terminalIsFocused().then((focused) => { + if (focused) return + if (tuiConfig.notifications === false) return + Notification.show("opencode", `Error: ${message}`) + }) }) sdk.event.on("installation.update-available", async (evt) => { diff --git a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx index 36095580fb08..fdaaaba6c0db 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/toast.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/toast.tsx @@ -1,19 +1,35 @@ -import { createContext, useContext, type ParentProps, Show } from "solid-js" +import { createContext, useContext, type ParentProps, Show, createMemo } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "@tui/context/theme" import { useTerminalDimensions } from "@opentui/solid" -import { SplitBorder } from "../component/border" import { TextAttributes } from "@opentui/core" import z from "zod" import { TuiEvent } from "../event" export type ToastOptions = z.infer +const VARIANT_ICONS: Record = { + error: "✗", + success: "✓", + info: "ℹ", + warning: "⚠", +} + export function Toast() { const toast = useToast() const { theme } = useTheme() const dimensions = useTerminalDimensions() + const icon = createMemo(() => { + if (!toast.currentToast) return "" + return VARIANT_ICONS[toast.currentToast.variant] ?? "●" + }) + + const iconColor = createMemo(() => { + if (!toast.currentToast) return theme.text + return theme[toast.currentToast.variant] ?? theme.text + }) + return ( {(current) => ( @@ -30,14 +46,18 @@ export function Toast() { paddingBottom={1} backgroundColor={theme.backgroundPanel} borderColor={theme[current().variant]} - border={["left", "right"]} - customBorderChars={SplitBorder.customBorderChars} + border={["top", "bottom", "left", "right"]} > - - - {current().title} + + + {icon()} - + + + {current().title} + + + {current().message} @@ -64,7 +84,7 @@ function init() { setStore("currentToast", null) }, duration).unref() }, - error: (err: any) => { + error: (err: unknown) => { if (err instanceof Error) return toast.show({ variant: "error", diff --git a/packages/opencode/src/config/tui-schema.ts b/packages/opencode/src/config/tui-schema.ts index b126d3c96a42..05ab851a4f3d 100644 --- a/packages/opencode/src/config/tui-schema.ts +++ b/packages/opencode/src/config/tui-schema.ts @@ -22,6 +22,11 @@ export const TuiOptions = z.object({ .enum(["auto", "stacked"]) .optional() .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), + notifications: z + .boolean() + .default(true) + .optional() + .describe("Show system notifications when sessions complete or error. Requires terminal focus-loss detection."), }) export const TuiInfo = z diff --git a/packages/opencode/src/notification/index.ts b/packages/opencode/src/notification/index.ts new file mode 100644 index 000000000000..bb17373d47ca --- /dev/null +++ b/packages/opencode/src/notification/index.ts @@ -0,0 +1,95 @@ +import { platform } from "os" +import { Process } from "@/util/process" +import { which } from "@/util/which" + +const TERMINAL_APPS = [ + "terminal", + "iterm", + "iterm2", + "warp", + "alacritty", + "kitty", + "ghostty", + "wezterm", + "hyper", + "tabby", + "wave", + "tmux", + "zellij", + "vscode", + "code", +] + +function escapeForOsascript(s: string): string { + return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') +} + +export namespace Notification { + export async function terminalIsFocused(): Promise { + if (platform() !== "darwin") return false + + const result = await Process.text( + [ + "osascript", + "-e", + 'tell application "System Events" to get name of first application process whose frontmost is true', + ], + { nothrow: true }, + ) + const frontmost = result.text.trim().toLowerCase() + return TERMINAL_APPS.some((app) => frontmost.includes(app)) + } + + export async function show(title: string, message: string): Promise { + const os = platform() + + if (os === "darwin") { + const escaped = escapeForOsascript(message) + const titleEscaped = escapeForOsascript(title) + await Process.run( + [ + "osascript", + "-e", + `tell application "Terminal" to display notification "${escaped}" with title "${titleEscaped}"`, + ], + { nothrow: true }, + ) + return + } + + if (os === "linux") { + if (which("notify-send")) { + await Process.run(["notify-send", "--app-name=opencode", title, message], { nothrow: true }) + return + } + if (which("notify")) { + await Process.run(["notify", title, message], { nothrow: true }) + return + } + return + } + + if (os === "win32") { + const script = [ + "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null", + "[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null", + `$template = "${escapeXml(title)}${escapeXml(message)}"`, + "$xml = New-Object Windows.Data.Xml.Dom.XmlDocument", + "$xml.LoadXml($template)", + "$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)", + '[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("opencode").Show($toast)', + ].join("; ") + await Process.run(["powershell.exe", "-NonInteractive", "-NoProfile", "-Command", script], { nothrow: true }) + return + } + } +} + +function escapeXml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} diff --git a/packages/web/src/content/docs/ar/plugins.mdx b/packages/web/src/content/docs/ar/plugins.mdx index d7c025bbc319..10de0789b0aa 100644 --- a/packages/web/src/content/docs/ar/plugins.mdx +++ b/packages/web/src/content/docs/ar/plugins.mdx @@ -225,7 +225,7 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr event: async ({ event }) => { // Send notification on session completion if (event.type === "session.idle") { - await $`osascript -e 'display notification "Session completed!" with title "opencode"'` + await $`osascript -e 'tell application "Terminal" to display notification "Session completed!" with title "opencode"'` } }, } diff --git a/packages/web/src/content/docs/bs/plugins.mdx b/packages/web/src/content/docs/bs/plugins.mdx index 7e046cb83dfd..475fafd6e609 100644 --- a/packages/web/src/content/docs/bs/plugins.mdx +++ b/packages/web/src/content/docs/bs/plugins.mdx @@ -216,7 +216,7 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr event: async ({ event }) => { // Send notification on session completion if (event.type === "session.idle") { - await $`osascript -e 'display notification "Session completed!" with title "opencode"'` + await $`osascript -e 'tell application "Terminal" to display notification "Session completed!" with title "opencode"'` } }, } diff --git a/packages/web/src/content/docs/da/plugins.mdx b/packages/web/src/content/docs/da/plugins.mdx index 908c6e111130..b99e45de746c 100644 --- a/packages/web/src/content/docs/da/plugins.mdx +++ b/packages/web/src/content/docs/da/plugins.mdx @@ -225,7 +225,7 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr event: async ({ event }) => { // Send notification on session completion if (event.type === "session.idle") { - await $`osascript -e 'display notification "Session completed!" with title "opencode"'` + await $`osascript -e 'tell application "Terminal" to display notification "Session completed!" with title "opencode"'` } }, } diff --git a/packages/web/src/content/docs/de/plugins.mdx b/packages/web/src/content/docs/de/plugins.mdx index 57fcdbba6e85..4520d77547b4 100644 --- a/packages/web/src/content/docs/de/plugins.mdx +++ b/packages/web/src/content/docs/de/plugins.mdx @@ -224,7 +224,7 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr event: async ({ event }) => { // Send notification on session completion if (event.type === "session.idle") { - await $`osascript -e 'display notification "Session completed!" with title "opencode"'` + await $`osascript -e 'tell application "Terminal" to display notification "Session completed!" with title "opencode"'` } }, } diff --git a/packages/web/src/content/docs/es/plugins.mdx b/packages/web/src/content/docs/es/plugins.mdx index dc4418072516..2993b701c407 100644 --- a/packages/web/src/content/docs/es/plugins.mdx +++ b/packages/web/src/content/docs/es/plugins.mdx @@ -225,7 +225,7 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr event: async ({ event }) => { // Send notification on session completion if (event.type === "session.idle") { - await $`osascript -e 'display notification "Session completed!" with title "opencode"'` + await $`osascript -e 'tell application "Terminal" to display notification "Session completed!" with title "opencode"'` } }, } diff --git a/packages/web/src/content/docs/fr/plugins.mdx b/packages/web/src/content/docs/fr/plugins.mdx index 48cdc4d116d6..2068f0c14920 100644 --- a/packages/web/src/content/docs/fr/plugins.mdx +++ b/packages/web/src/content/docs/fr/plugins.mdx @@ -224,7 +224,7 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr event: async ({ event }) => { // Send notification on session completion if (event.type === "session.idle") { - await $`osascript -e 'display notification "Session completed!" with title "opencode"'` + await $`osascript -e 'tell application "Terminal" to display notification "Session completed!" with title "opencode"'` } }, } diff --git a/packages/web/src/content/docs/it/plugins.mdx b/packages/web/src/content/docs/it/plugins.mdx index 3fc22a78c565..90716588099c 100644 --- a/packages/web/src/content/docs/it/plugins.mdx +++ b/packages/web/src/content/docs/it/plugins.mdx @@ -224,7 +224,7 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr event: async ({ event }) => { // Send notification on session completion if (event.type === "session.idle") { - await $`osascript -e 'display notification "Session completed!" with title "opencode"'` + await $`osascript -e 'tell application "Terminal" to display notification "Session completed!" with title "opencode"'` } }, } diff --git a/packages/web/src/content/docs/ja/plugins.mdx b/packages/web/src/content/docs/ja/plugins.mdx index 06a0dca9adf9..058777a612e5 100644 --- a/packages/web/src/content/docs/ja/plugins.mdx +++ b/packages/web/src/content/docs/ja/plugins.mdx @@ -225,7 +225,7 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr event: async ({ event }) => { // Send notification on session completion if (event.type === "session.idle") { - await $`osascript -e 'display notification "Session completed!" with title "opencode"'` + await $`osascript -e 'tell application "Terminal" to display notification "Session completed!" with title "opencode"'` } }, } diff --git a/packages/web/src/content/docs/ko/plugins.mdx b/packages/web/src/content/docs/ko/plugins.mdx index f20eb90c46cb..6ad660c4d9ad 100644 --- a/packages/web/src/content/docs/ko/plugins.mdx +++ b/packages/web/src/content/docs/ko/plugins.mdx @@ -224,7 +224,7 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr event: async ({ event }) => { // Send notification on session completion if (event.type === "session.idle") { - await $`osascript -e 'display notification "Session completed!" with title "opencode"'` + await $`osascript -e 'tell application "Terminal" to display notification "Session completed!" with title "opencode"'` } }, } diff --git a/packages/web/src/content/docs/nb/plugins.mdx b/packages/web/src/content/docs/nb/plugins.mdx index 27d23ec63a83..42fd1903baee 100644 --- a/packages/web/src/content/docs/nb/plugins.mdx +++ b/packages/web/src/content/docs/nb/plugins.mdx @@ -225,7 +225,7 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr event: async ({ event }) => { // Send notification on session completion if (event.type === "session.idle") { - await $`osascript -e 'display notification "Session completed!" with title "opencode"'` + await $`osascript -e 'tell application "Terminal" to display notification "Session completed!" with title "opencode"'` } }, } diff --git a/packages/web/src/content/docs/pl/plugins.mdx b/packages/web/src/content/docs/pl/plugins.mdx index 510c8a22e4c3..2ea2494bf244 100644 --- a/packages/web/src/content/docs/pl/plugins.mdx +++ b/packages/web/src/content/docs/pl/plugins.mdx @@ -225,7 +225,7 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr event: async ({ event }) => { // Send notification on session completion if (event.type === "session.idle") { - await $`osascript -e 'display notification "Session completed!" with title "opencode"'` + await $`osascript -e 'tell application "Terminal" to display notification "Session completed!" with title "opencode"'` } }, } diff --git a/packages/web/src/content/docs/pt-br/plugins.mdx b/packages/web/src/content/docs/pt-br/plugins.mdx index 54c5b1ffaca8..096c13907bb4 100644 --- a/packages/web/src/content/docs/pt-br/plugins.mdx +++ b/packages/web/src/content/docs/pt-br/plugins.mdx @@ -224,7 +224,7 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr event: async ({ event }) => { // Send notification on session completion if (event.type === "session.idle") { - await $`osascript -e 'display notification "Session completed!" with title "opencode"'` + await $`osascript -e 'tell application "Terminal" to display notification "Session completed!" with title "opencode"'` } }, } diff --git a/packages/web/src/content/docs/ru/plugins.mdx b/packages/web/src/content/docs/ru/plugins.mdx index 1d7ffb339be9..910a4c571a1e 100644 --- a/packages/web/src/content/docs/ru/plugins.mdx +++ b/packages/web/src/content/docs/ru/plugins.mdx @@ -225,7 +225,7 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr event: async ({ event }) => { // Send notification on session completion if (event.type === "session.idle") { - await $`osascript -e 'display notification "Session completed!" with title "opencode"'` + await $`osascript -e 'tell application "Terminal" to display notification "Session completed!" with title "opencode"'` } }, } diff --git a/packages/web/src/content/docs/th/plugins.mdx b/packages/web/src/content/docs/th/plugins.mdx index e672715a4efe..0073ed26a2ad 100644 --- a/packages/web/src/content/docs/th/plugins.mdx +++ b/packages/web/src/content/docs/th/plugins.mdx @@ -225,7 +225,7 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr event: async ({ event }) => { // Send notification on session completion if (event.type === "session.idle") { - await $`osascript -e 'display notification "Session completed!" with title "opencode"'` + await $`osascript -e 'tell application "Terminal" to display notification "Session completed!" with title "opencode"'` } }, } diff --git a/packages/web/src/content/docs/tr/plugins.mdx b/packages/web/src/content/docs/tr/plugins.mdx index 4926f5f70e55..bf7233fffafa 100644 --- a/packages/web/src/content/docs/tr/plugins.mdx +++ b/packages/web/src/content/docs/tr/plugins.mdx @@ -224,7 +224,7 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr event: async ({ event }) => { // Send notification on session completion if (event.type === "session.idle") { - await $`osascript -e 'display notification "Session completed!" with title "opencode"'` + await $`osascript -e 'tell application "Terminal" to display notification "Session completed!" with title "opencode"'` } }, } diff --git a/packages/web/src/content/docs/zh-cn/plugins.mdx b/packages/web/src/content/docs/zh-cn/plugins.mdx index e8a8bd70cbc3..c106f87cce38 100644 --- a/packages/web/src/content/docs/zh-cn/plugins.mdx +++ b/packages/web/src/content/docs/zh-cn/plugins.mdx @@ -224,7 +224,7 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr event: async ({ event }) => { // Send notification on session completion if (event.type === "session.idle") { - await $`osascript -e 'display notification "Session completed!" with title "opencode"'` + await $`osascript -e 'tell application "Terminal" to display notification "Session completed!" with title "opencode"'` } }, } diff --git a/packages/web/src/content/docs/zh-tw/plugins.mdx b/packages/web/src/content/docs/zh-tw/plugins.mdx index 8163c291e002..8444dea117fd 100644 --- a/packages/web/src/content/docs/zh-tw/plugins.mdx +++ b/packages/web/src/content/docs/zh-tw/plugins.mdx @@ -224,7 +224,7 @@ export const NotificationPlugin = async ({ project, client, $, directory, worktr event: async ({ event }) => { // Send notification on session completion if (event.type === "session.idle") { - await $`osascript -e 'display notification "Session completed!" with title "opencode"'` + await $`osascript -e 'tell application "Terminal" to display notification "Session completed!" with title "opencode"'` } }, }