Skip to content
Draft
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,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"
Expand Down Expand Up @@ -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

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
5 changes: 4 additions & 1 deletion packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useSync } from "@tui/context/sync"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../context/tui-config"
import { InstallationVersion } from "@/installation/version"
import { InstallationChannel, InstallationVersion } from "@/installation/version"
import { TuiPluginRuntime } from "../../plugin"

import { getScrollAcceleration } from "../../util/scroll"
Expand Down Expand Up @@ -62,6 +62,9 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<text fg={theme.text}>
<b>{session()!.title}</b>
</text>
<Show when={InstallationChannel !== "latest"}>
<text fg={theme.textMuted}>{props.sessionID}</text>
</Show>
<Show when={session()!.workspaceID}>
<text fg={theme.textMuted}>
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}>●</span>{" "}
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"
}

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 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\\`
}
2 changes: 1 addition & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export const Info = z
.optional()
.describe("Agent configuration, see https://opencode.ai/docs/agents"),
provider: z
.record(z.string(), ConfigProvider.Info)
.record(z.string(), ConfigProvider.Info.zod)
.optional()
.describe("Custom provider configurations and model overrides"),
mcp: z
Expand Down
Loading
Loading