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
56 changes: 56 additions & 0 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -151,6 +152,46 @@ export const RunCommand = cmd({
return false
}

const notifiedMessages = new Set<string>()
const messageTextParts = new Map<string, Map<string, string>>()

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

Expand All @@ -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]
Expand Down Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -178,6 +179,61 @@ function App() {
const sync = useSync()
const exit = useExit()
const promptRef = usePromptRef()
const notifiedMessages = new Set<string>()
const messageTextParts = new Map<string, Map<string, string>>()

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) => {
Expand Down Expand Up @@ -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)
})
Expand Down
36 changes: 36 additions & 0 deletions packages/opencode/src/cli/util/osc.ts
Original file line number Diff line number Diff line change
@@ -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`)
}