diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index a58ff05648..4d3456f1f4 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -1,5 +1,6 @@ import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import * as Clipboard from "@tui/util/clipboard" +import * as Notify from "@tui/util/notify" import * as Selection from "@tui/util/selection" import * as Terminal from "@tui/util/terminal" import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" @@ -58,8 +59,11 @@ import open from "open" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" import { TuiConfig } from "@/cli/cmd/tui/config/tui" +import { Permission } from "@/permission" import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" +import { Question } from "@/question" import { FormatError, FormatUnknownError } from "@/cli/error" +import { SessionStatus } from "@/session/status" import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" @@ -781,6 +785,31 @@ function App(props: { onSnapshot?: () => Promise }) { }) }) + const notificationMethod = tuiConfig.notification_method ?? "off" + const notifySession = (sessionID: string, prefix: string) => { + const session = sync.session.get(sessionID) + if (session?.parentID) return + Notify.notifyTerminal({ + method: notificationMethod, + title: "OpenCode", + body: `${prefix}: ${session?.title ?? sessionID}`, + }) + } + + event.subscribe((evt) => { + if (notificationMethod === "off") return + if (evt.type === SessionStatus.Event.Idle.type) { + notifySession(evt.properties.sessionID, "Response ready") + return + } + if (evt.type === Permission.Event.Asked.type) { + notifySession(evt.properties.sessionID, "Permission required") + return + } + if (evt.type !== Question.Event.Asked.type) return + notifySession(evt.properties.sessionID, "Question asked") + }) + event.on("installation.update-available", async (evt) => { const version = evt.properties.version diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts index a7f50ddf9d..f9e5f2d5fc 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts @@ -20,6 +20,7 @@ const TuiLegacy = z scroll_speed: TuiOptions.shape.scroll_speed.catch(undefined), scroll_acceleration: TuiOptions.shape.scroll_acceleration.catch(undefined), diff_style: TuiOptions.shape.diff_style.catch(undefined), + notification_method: TuiOptions.shape.notification_method.catch(undefined), }) .strip() @@ -89,7 +90,8 @@ function normalizeTui(data: Record) { if ( parsed.scroll_speed === undefined && parsed.diff_style === undefined && - parsed.scroll_acceleration === undefined + parsed.scroll_acceleration === undefined && + parsed.notification_method === undefined ) { return } diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index ed79e8e524..44700a225e 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -1,6 +1,7 @@ import z from "zod" import { ConfigPlugin } from "@/config/plugin" import { ConfigKeybinds } from "@/config/keybinds" +import { NOTIFICATION_METHODS } from "../util/notify" const KeybindOverride = z .object( @@ -24,6 +25,10 @@ export const TuiOptions = z.object({ .optional() .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), mouse: z.boolean().optional().describe("Enable or disable mouse capture (default: true)"), + notification_method: z + .enum(NOTIFICATION_METHODS) + .optional() + .describe("Select how terminal notifications are emitted for response-ready and attention-needed events"), }) export const TuiInfo = z diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 8c535833c6..462d132c65 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -5,6 +5,7 @@ import path from "path" import fs from "fs/promises" import * as Filesystem from "../../../../util/filesystem" import * as Process from "../../../../util/process" +import { wrapOscSequence } from "./osc" // Lazy load which and clipboardy to avoid expensive execa/which/isexe chain at startup const getWhich = lazy(async () => { @@ -25,10 +26,7 @@ const getClipboardy = lazy(async () => { function writeOsc52(text: string): void { if (!process.stdout.isTTY) return const base64 = Buffer.from(text).toString("base64") - const osc52 = `\x1b]52;c;${base64}\x07` - const passthrough = process.env["TMUX"] || process.env["STY"] - const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52 - process.stdout.write(sequence) + process.stdout.write(wrapOscSequence(`\x1b]52;c;${base64}\x07`)) } export interface Content { diff --git a/packages/opencode/src/cli/cmd/tui/util/notify.ts b/packages/opencode/src/cli/cmd/tui/util/notify.ts new file mode 100644 index 0000000000..7306d2ae83 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/notify.ts @@ -0,0 +1,71 @@ +import { wrapOscSequence } from "./osc" + +const MAX_LENGTH = 180 + +export const NOTIFICATION_METHODS = ["auto", "osc9", "osc777", "bell", "off"] as const + +export type NotificationMethod = (typeof NOTIFICATION_METHODS)[number] + +export function resolveNotificationMethod( + method: NotificationMethod | undefined, + env: NodeJS.ProcessEnv = process.env, +): Exclude { + if (method && method !== "auto") return method + if (env.TERM_PROGRAM === "vscode") return "bell" + if (env.KITTY_WINDOW_ID || env.TERM === "xterm-kitty") return "osc777" + if (env.TERM_PROGRAM === "WezTerm") return "osc777" + if (env.VTE_VERSION || env.TERM?.startsWith("foot")) return "osc777" + if (env.TERM_PROGRAM === "iTerm.app") return "osc9" + if (env.TERM_PROGRAM === "ghostty") return "osc9" + if (env.TERM_PROGRAM === "Apple_Terminal") return "osc9" + if (env.TERM_PROGRAM === "WarpTerminal") return "osc9" + if (env.WT_SESSION) return "bell" + return "bell" +} + +export function sanitizeNotificationText(value: string) { + return value + .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ") + .replace(/;/g, ":") + .replace(/\s+/g, " ") + .trim() + .slice(0, MAX_LENGTH) +} + +export function formatNotificationSequence(input: { + method: Exclude + title: string + body?: string +}) { + if (input.method === "off") return "" + if (input.method === "bell") return "\x07" + if (input.method === "osc9") { + return `\x1b]9;${sanitizeNotificationText([input.title, input.body].filter(Boolean).join(": "))}\x07` + } + return `\x1b]777;notify;${sanitizeNotificationText(input.title)};${sanitizeNotificationText(input.body ?? "")}\x07` +} + +export function notifyTerminal(input: { + title: string + body?: string + method?: NotificationMethod + env?: NodeJS.ProcessEnv + write?: (chunk: string) => void +}) { + const env = input.env ?? process.env + const method = resolveNotificationMethod(input.method, env) + const sequence = wrapOscSequence( + formatNotificationSequence({ + method, + title: input.title, + body: input.body, + }), + env, + ) + if (!sequence) return false + const write = + input.write ?? + ((chunk: string) => (process.stderr.isTTY ? process.stderr.write(chunk) : process.stdout.write(chunk))) + write(sequence) + return true +} diff --git a/packages/opencode/src/cli/cmd/tui/util/osc.ts b/packages/opencode/src/cli/cmd/tui/util/osc.ts new file mode 100644 index 0000000000..1b992ef69a --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/osc.ts @@ -0,0 +1,5 @@ +export function wrapOscSequence(sequence: string, env: NodeJS.ProcessEnv = process.env) { + if (!sequence) return sequence + if (!env.TMUX && !env.STY) return sequence + return `\x1bPtmux;${sequence.replaceAll("\x1b", "\x1b\x1b")}\x1b\\` +} diff --git a/packages/opencode/test/cli/tui/notify.test.ts b/packages/opencode/test/cli/tui/notify.test.ts new file mode 100644 index 0000000000..1c517a629c --- /dev/null +++ b/packages/opencode/test/cli/tui/notify.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from "bun:test" + +const { formatNotificationSequence, notifyTerminal, resolveNotificationMethod, sanitizeNotificationText } = + await import("../../../src/cli/cmd/tui/util/notify") +const { wrapOscSequence } = await import("../../../src/cli/cmd/tui/util/osc") + +test("resolveNotificationMethod picks osc9 for iTerm", () => { + expect(resolveNotificationMethod("auto", { TERM_PROGRAM: "iTerm.app" })).toBe("osc9") +}) + +test("resolveNotificationMethod picks osc777 for kitty and bell for vscode", () => { + expect(resolveNotificationMethod("auto", { KITTY_WINDOW_ID: "1" })).toBe("osc777") + expect(resolveNotificationMethod("auto", { TERM_PROGRAM: "vscode" })).toBe("bell") +}) + +test("sanitizeNotificationText removes controls and semicolons", () => { + expect(sanitizeNotificationText("hello;\nworld\x07")).toBe("hello: world") +}) + +test("formatNotificationSequence emits osc9 and osc777 payloads", () => { + expect(formatNotificationSequence({ method: "osc9", title: "OpenCode", body: "Response ready" })).toBe( + "\x1b]9;OpenCode: Response ready\x07", + ) + expect(formatNotificationSequence({ method: "osc777", title: "OpenCode", body: "Permission required" })).toBe( + "\x1b]777;notify;OpenCode;Permission required\x07", + ) +}) + +test("wrapOscSequence escapes OSC sequences for passthrough", () => { + expect(wrapOscSequence("\x1b]9;done\x07", { TMUX: "/tmp/tmux" })).toBe("\x1bPtmux;\x1b\x1b]9;done\x07\x1b\\") +}) + +test("notifyTerminal writes the resolved sequence", () => { + let output = "" + expect( + notifyTerminal({ + title: "OpenCode", + body: "Question asked", + method: "auto", + env: { TERM_PROGRAM: "ghostty" }, + write: (chunk) => { + output += chunk + }, + }), + ).toBe(true) + expect(output).toBe("\x1b]9;OpenCode: Question asked\x07") +}) diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index c7b6d4a504..f585414a8c 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -624,3 +624,39 @@ test("merges plugin_enabled flags across config layers", async () => { "local.plugin": true, }) }) + +test("loads notification config from tui.json", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ notification_method: "osc777" }, null, 2)) + }, + }) + + const config = await getTuiConfig(tmp.path) + expect(config.notification_method).toBe("osc777") +}) + +test("migrates legacy notification settings from opencode.json", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + tui: { notification_method: "bell" }, + }, + null, + 2, + ), + ) + }, + }) + + const config = await getTuiConfig(tmp.path) + expect(config.notification_method).toBe("bell") + + const text = await Filesystem.readText(path.join(tmp.path, "tui.json")) + expect(JSON.parse(text)).toMatchObject({ + notification_method: "bell", + }) +})