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
16 changes: 16 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -816,6 +817,15 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}
})

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
Expand All @@ -826,6 +836,12 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
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) => {
Expand Down
38 changes: 29 additions & 9 deletions packages/opencode/src/cli/cmd/tui/ui/toast.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof TuiEvent.ToastShow.properties>

const VARIANT_ICONS: Record<string, string> = {
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 (
<Show when={toast.currentToast}>
{(current) => (
Expand All @@ -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"]}
>
<Show when={current().title}>
<text attributes={TextAttributes.BOLD} marginBottom={1} fg={theme.text}>
{current().title}
<box flexDirection="row" gap={1} marginBottom={1}>
<text fg={iconColor()} attributes={TextAttributes.BOLD}>
{icon()}
</text>
</Show>
<Show when={current().title}>
<text attributes={TextAttributes.BOLD} fg={theme.text}>
{current().title}
</text>
</Show>
</box>
<text fg={theme.text} wrapMode="word" width="100%">
{current().message}
</text>
Expand All @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/config/tui-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions packages/opencode/src/notification/index.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<void> {
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 = "<toast><visual><binding template='ToastText02'><text id='1'>${escapeXml(title)}</text><text id='2'>${escapeXml(message)}</text></binding></visual></toast>"`,
"$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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;")
}
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/ar/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"'`
}
},
}
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/bs/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"'`
}
},
}
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/da/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"'`
}
},
}
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/de/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"'`
}
},
}
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/es/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"'`
}
},
}
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/fr/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"'`
}
},
}
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/it/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"'`
}
},
}
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/ja/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"'`
}
},
}
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/ko/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"'`
}
},
}
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/nb/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"'`
}
},
}
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/pl/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"'`
}
},
}
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/pt-br/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"'`
}
},
}
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/ru/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"'`
}
},
}
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/th/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"'`
}
},
}
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/tr/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"'`
}
},
}
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/zh-cn/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"'`
}
},
}
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/content/docs/zh-tw/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"'`
}
},
}
Expand Down
Loading