Skip to content
Closed
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
29 changes: 29 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -58,7 +59,9 @@ 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 type { EventSource } from "./context/sdk"
Expand Down Expand Up @@ -781,6 +784,32 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
})
})

const notificationMethod = tuiConfig.notification_method ?? "auto"
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.on("session.idle", (evt) => {
if (notificationMethod === "off") return
notifySession(evt.properties.sessionID, "Response ready")
})

event.on("permission.asked", (evt) => {
if (notificationMethod === "off") return
notifySession(evt.properties.sessionID, "Permission required")
})

event.on("question.asked", (evt) => {
if (notificationMethod === "off") return
notifySession(evt.properties.sessionID, "Question asked")
})

event.on("installation.update-available", async (evt) => {
const version = evt.properties.version

Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
}
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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
Expand Down
6 changes: 2 additions & 4 deletions packages/opencode/src/cli/cmd/tui/util/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 {
Expand Down
71 changes: 71 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/notify.ts
Original file line number Diff line number Diff line change
@@ -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"
}

function sanitizeNotificationText(value: string) {
return value
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
.replace(/;/g, ":")
.replace(/\s+/g, " ")
.trim()
.slice(0, MAX_LENGTH)
}

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 changes: 5 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/osc.ts
Original file line number Diff line number Diff line change
@@ -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\\`
}
Loading