diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index bd9d29b4deb..1aeb9afad13 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,6 +1,7 @@ import type { Argv } from "yargs" import path from "path" import { UI } from "../ui" +import { buildOsc9Notification } from "../util/osc" import { cmd } from "./cmd" import { Flag } from "../../flag/flag" import { bootstrap } from "../bootstrap" @@ -151,6 +152,46 @@ export const RunCommand = cmd({ return false } + const notifiedMessages = new Set() + const messageTextParts = new Map>() + + const recordTextPart = (part: { messageID: string; id: string; text: string; synthetic?: boolean; ignored?: boolean }) => { + if (part.synthetic || part.ignored || !part.text) return + let perMessage = messageTextParts.get(part.messageID) + if (!perMessage) { + perMessage = new Map() + messageTextParts.set(part.messageID, perMessage) + } + perMessage.set(part.id, part.text) + } + + const buildPreview = (messageID: string) => { + const parts = messageTextParts.get(messageID) + if (!parts) return "" + return Array.from(parts.values()).join("\n") + } + + const canNotify = () => process.stdout.isTTY && args.format !== "json" + + const notifyOsc9 = (messageID: string) => { + if (!canNotify()) return + const preview = buildPreview(messageID) + const payload = buildOsc9Notification(preview, { fallback: "OpenCode response complete" }) + if (!payload) return + process.stdout.write(payload) + } + + const shouldNotifyAssistant = (info: { + role: string + time?: { completed?: number } + finish?: string + }) => { + if (info.role !== "assistant") return false + if (!info.time?.completed) return false + if (!info.finish) return true + return !["tool-calls", "unknown"].includes(info.finish) + } + const events = await sdk.event.subscribe() let errorMsg: string | undefined @@ -160,6 +201,10 @@ export const RunCommand = cmd({ const part = event.properties.part if (part.sessionID !== sessionID) continue + if (part.type === "text") { + recordTextPart(part) + } + if (part.type === "tool" && part.state.status === "completed") { if (outputJsonEvent("tool_use", { part })) continue const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD] @@ -190,6 +235,17 @@ export const RunCommand = cmd({ } } + if (event.type === "message.updated") { + const info = event.properties.info + if (info.sessionID !== sessionID) continue + if (info.role !== "assistant" || !info.time?.completed) continue + if (shouldNotifyAssistant(info) && !notifiedMessages.has(info.id)) { + notifiedMessages.add(info.id) + notifyOsc9(info.id) + } + messageTextParts.delete(info.id) + } + if (event.type === "session.error") { const props = event.properties if (props.sessionID !== sessionID || !props.error) continue diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 91be02bb978..25413861b83 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -35,6 +35,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { writeHeapSnapshot } from "v8" import { PromptRefProvider, usePromptRef } from "./context/prompt" +import { buildOsc9Notification } from "../../util/osc" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { // can't set raw mode if not a TTY @@ -178,6 +179,61 @@ function App() { const sync = useSync() const exit = useExit() const promptRef = usePromptRef() + const notifiedMessages = new Set() + const messageTextParts = new Map>() + + const recordTextPart = (part: { + messageID: string + id: string + text: string + synthetic?: boolean + ignored?: boolean + }) => { + if (part.synthetic || part.ignored || !part.text) return + let perMessage = messageTextParts.get(part.messageID) + if (!perMessage) { + perMessage = new Map() + messageTextParts.set(part.messageID, perMessage) + } + perMessage.set(part.id, part.text) + } + + const buildPreview = (messageID: string) => { + const parts = sync.data.part[messageID] + const textParts: string[] = [] + if (parts) { + for (const part of parts) { + if (part.type !== "text" || part.synthetic || part.ignored) continue + textParts.push(part.text) + } + } + if (textParts.length === 0) { + const cached = messageTextParts.get(messageID) + if (cached) textParts.push(...cached.values()) + } + return textParts.join("\n") + } + + const notifyOsc9 = (messageID: string) => { + if (!process.stdout.isTTY) return + const preview = buildPreview(messageID) + const payload = buildOsc9Notification(preview, { fallback: "OpenCode response complete" }) + if (!payload) return + // @ts-expect-error writeOut is not in type definitions + renderer.writeOut(payload) + messageTextParts.delete(messageID) + } + + const shouldNotifyAssistant = (info: { + role: string + time?: { completed?: number } + finish?: string + }) => { + if (info.role !== "assistant") return false + if (!info.time?.completed) return false + if (!info.finish) return true + return !["tool-calls", "unknown"].includes(info.finish) + } // Wire up console copy-to-clipboard via opentui's onCopySelection callback renderer.console.onCopySelection = async (text: string) => { @@ -537,6 +593,21 @@ function App() { } }) + sdk.event.on("message.part.updated", (evt) => { + const part = evt.properties.part + if (part.type === "text") recordTextPart(part) + }) + + sdk.event.on("message.updated", (evt) => { + const info = evt.properties.info + if (info.role !== "assistant" || !info.time?.completed) return + if (shouldNotifyAssistant(info) && !notifiedMessages.has(info.id)) { + notifiedMessages.add(info.id) + notifyOsc9(info.id) + } + messageTextParts.delete(info.id) + }) + sdk.event.on(TuiEvent.CommandExecute.type, (evt) => { command.trigger(evt.properties.command) }) diff --git a/packages/opencode/src/cli/util/osc.ts b/packages/opencode/src/cli/util/osc.ts new file mode 100644 index 00000000000..3d56435d215 --- /dev/null +++ b/packages/opencode/src/cli/util/osc.ts @@ -0,0 +1,36 @@ +const OSC9_DEFAULT_MAX_LENGTH = 200 + +export type Osc9NotificationOptions = { + fallback?: string + maxLength?: number + prefix?: string +} + +function normalizeNotificationText(input: string, maxLength: number) { + const sanitized = input.replace(/[\x07\x1b\x9c]/g, "") + const collapsed = sanitized.replace(/\s+/g, " ").trim() + if (!collapsed) return "" + + const limit = Math.max(1, maxLength) + if (collapsed.length <= limit) return collapsed + if (limit <= 3) return collapsed.slice(0, limit) + return collapsed.slice(0, limit - 3) + "..." +} + +function wrapForTmux(sequence: string) { + if (!process.env["TMUX"]) return sequence + return `\x1bPtmux;\x1b${sequence}\x1b\\` +} + +export function buildOsc9Notification(input: string, options?: Osc9NotificationOptions) { + const maxLength = options?.maxLength ?? OSC9_DEFAULT_MAX_LENGTH + let message = normalizeNotificationText(input, maxLength) + if (!message && options?.fallback) { + message = normalizeNotificationText(options.fallback, maxLength) + } + if (!message) return undefined + if (options?.prefix) { + message = `${options.prefix} ${message}` + } + return wrapForTmux(`\x1b]9;${message}\x07`) +}