mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-04-20 21:00:29 +08:00
feat(tui): add terminal notifications
Surface response-ready and attention-needed TUI events through configurable OSC or bell signals so users can opt into built-in terminal notifications without plugins.
This commit is contained in:
@@ -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<string[]> }) {
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
if (
|
||||
parsed.scroll_speed === undefined &&
|
||||
parsed.diff_style === undefined &&
|
||||
parsed.scroll_acceleration === undefined
|
||||
parsed.scroll_acceleration === undefined &&
|
||||
parsed.notification_method === undefined
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
71
packages/opencode/src/cli/cmd/tui/util/notify.ts
Normal file
71
packages/opencode/src/cli/cmd/tui/util/notify.ts
Normal file
@@ -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<NotificationMethod, "auto"> {
|
||||
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<NotificationMethod, "auto">
|
||||
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
|
||||
}
|
||||
5
packages/opencode/src/cli/cmd/tui/util/osc.ts
Normal file
5
packages/opencode/src/cli/cmd/tui/util/osc.ts
Normal file
@@ -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\\`
|
||||
}
|
||||
47
packages/opencode/test/cli/tui/notify.test.ts
Normal file
47
packages/opencode/test/cli/tui/notify.test.ts
Normal file
@@ -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")
|
||||
})
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user