attention api

This commit is contained in:
Sebastian Herrlinger
2026-05-11 23:40:37 +02:00
parent a7b44afdba
commit f6fcee9b9e
13 changed files with 653 additions and 4 deletions
+6 -2
View File
@@ -63,6 +63,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { createTuiApi } from "@/cli/cmd/tui/plugin/api"
import type { RouteMap } from "@/cli/cmd/tui/plugin/api"
import { createTuiAttention, type TuiAttentionHost } from "@/cli/cmd/tui/attention"
import { FormatError, FormatUnknownError } from "@/cli/error"
import { CommandPaletteProvider, useCommandPalette } from "./context/command-palette"
import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencodeKeymap } from "./keymap"
@@ -180,9 +181,11 @@ export function tui(input: {
const onBeforeExit = async () => {
offKeymap()
await TuiPluginRuntime.dispose()
attention.dispose()
}
const renderer = await createCliRenderer(rendererConfig(input.config))
const attention = createTuiAttention({ renderer, config: input.config })
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
@@ -232,7 +235,7 @@ export function tui(input: {
<PromptHistoryProvider>
<PromptRefProvider>
<EditorContextProvider>
<App onSnapshot={input.onSnapshot} />
<App onSnapshot={input.onSnapshot} attention={attention} />
</EditorContextProvider>
</PromptRefProvider>
</PromptHistoryProvider>
@@ -259,7 +262,7 @@ export function tui(input: {
})
}
function App(props: { onSnapshot?: () => Promise<string[]> }) {
function App(props: { onSnapshot?: () => Promise<string[]>; attention: TuiAttentionHost }) {
const tuiConfig = useTuiConfig()
const route = useRoute()
const dimensions = useTerminalDimensions()
@@ -298,6 +301,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
theme: themeState,
toast,
renderer,
attention: props.attention,
})
const [ready, setReady] = createSignal(false)
TuiPluginRuntime.init({
@@ -0,0 +1,187 @@
import { Audio, type AudioErrorContext, type AudioSound } from "@opentui/core"
import type {
TuiAttention,
TuiAttentionNotifyInput,
TuiAttentionNotifyResult,
TuiAttentionNotifySkipReason,
} from "@opencode-ai/plugin/tui"
import stripAnsi from "strip-ansi"
import type { TuiConfig } from "./config/tui"
import attentionSoundPath from "./asset/pulse-a.wav" with { type: "file" }
import * as Log from "@opencode-ai/core/util/log"
type FocusState = "unknown" | "focused" | "blurred"
type AttentionRenderer = {
readonly isDestroyed: boolean
on(event: "focus" | "blur", listener: () => void): unknown
off(event: "focus" | "blur", listener: () => void): unknown
triggerNotification(message: string, title?: string): boolean
}
type AttentionAudioEngine = {
on(event: "error", listener: (error: Error, context: AudioErrorContext) => void): unknown
isStarted(): boolean
start(): boolean
loadSound(data: Uint8Array | ArrayBuffer): AudioSound | null
play(sound: AudioSound, options?: { volume?: number }): unknown | null
dispose(): void
}
type AttentionAudio = {
create(): AttentionAudioEngine
bytes(): Promise<Uint8Array>
}
export type TuiAttentionHost = TuiAttention & {
dispose(): void
}
const log = Log.create({ service: "tui.attention" })
const DEFAULT_TITLE = "opencode"
const TITLE_LIMIT = 80
const MESSAGE_LIMIT = 240
function skipped(reason: TuiAttentionNotifySkipReason): TuiAttentionNotifyResult {
return {
ok: false,
notification: false,
sound: false,
skipped: reason,
}
}
function normalizeText(input: string | undefined, fallback: string, limit: number) {
const text = stripAnsi(input ?? "")
.replace(/[ \t]*[\r\n]+[ \t]*/g, " ")
.replace(/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, "")
.trim()
const normalized = text.length ? text : fallback
return Array.from(normalized).slice(0, limit).join("")
}
function clampVolume(volume: number) {
if (!Number.isFinite(volume)) return 0
return Math.min(1, Math.max(0, volume))
}
function soundVolume(input: TuiAttentionNotifyInput, config: Pick<TuiConfig.Resolved, "attention">) {
if (!config.attention.sound) return
if (input.sound === undefined || input.sound === false) return
if (input.sound === true) return clampVolume(config.attention.volume)
if (input.sound.enabled === false) return
return clampVolume(input.sound.volume ?? config.attention.volume)
}
export function createTuiAttention(input: {
renderer: AttentionRenderer
config: Pick<TuiConfig.Resolved, "attention">
audio?: AttentionAudio
}): TuiAttentionHost {
let focus: FocusState = "unknown"
let disposed = false
let audio: AttentionAudioEngine | undefined
let sound: AudioSound | null | undefined
let soundTask: Promise<AudioSound | null> | undefined
const audioInput =
input.audio ??
({
create: () => {
const engine = Audio.create({ autoStart: false })
engine.on("error", (error, context) => {
log.debug("attention audio error", { error, context })
})
return engine
},
bytes: () => Bun.file(attentionSoundPath).bytes(),
} satisfies AttentionAudio)
const onFocus = () => {
focus = "focused"
}
const onBlur = () => {
focus = "blurred"
}
input.renderer.on("focus", onFocus)
input.renderer.on("blur", onBlur)
async function loadSound() {
if (!audio) return null
if (sound !== undefined) return sound
soundTask ??= audioInput
.bytes()
.then((bytes) => audio?.loadSound(bytes) ?? null)
.catch((error) => {
log.debug("failed to load attention sound", { error })
return null
})
sound = await soundTask
return sound
}
async function playSound(volume: number) {
try {
audio ??= audioInput.create()
if (!audio.isStarted() && !audio.start()) return false
const current = await loadSound()
if (current == null) return false
return audio.play(current, { volume }) != null
} catch (error) {
log.debug("failed to play attention sound", { error })
return false
}
}
return {
async notify(request) {
try {
if (!input.config.attention.enabled) return skipped("attention_disabled")
if (disposed || input.renderer.isDestroyed) return skipped("renderer_destroyed")
const message = normalizeText(request.message, "", MESSAGE_LIMIT)
if (!message) return skipped("empty_message")
if (focus === "focused") return skipped("focused")
if (focus === "unknown") return skipped("focus_unknown")
const notification = input.config.attention.notifications
? (() => {
try {
return input.renderer.triggerNotification(message, normalizeText(request.title, DEFAULT_TITLE, TITLE_LIMIT))
} catch (error) {
log.debug("failed to trigger attention notification", { error })
return false
}
})()
: false
const volume = soundVolume(request, input.config)
const sound = volume === undefined ? false : await playSound(volume)
return {
ok: notification || sound,
notification,
sound,
}
} catch (error) {
log.debug("failed to handle attention notification", { error })
return {
ok: false,
notification: false,
sound: false,
}
}
},
dispose() {
disposed = true
input.renderer.off("focus", onFocus)
input.renderer.off("blur", onBlur)
audio?.dispose()
audio = undefined
sound = undefined
soundTask = undefined
},
}
}
@@ -17,6 +17,13 @@ export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({
description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
})
export const Attention = Schema.Struct({
enabled: Schema.optional(Schema.Boolean),
notifications: Schema.optional(Schema.Boolean),
sound: Schema.optional(Schema.Boolean),
volume: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1))),
}).annotate({ description: "Attention notification and sound settings" })
export const TuiInfo = Schema.Struct({
$schema: Schema.optional(Schema.String),
theme: Schema.optional(Schema.String),
@@ -24,6 +31,7 @@ export const TuiInfo = Schema.Struct({
plugin: Schema.optional(Schema.Array(ConfigPlugin.Spec)),
plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
leader_timeout: Schema.optional(KeymapLeaderTimeout),
attention: Schema.optional(Attention),
scroll_speed: Schema.optional(ScrollSpeed).annotate({
description: "TUI scroll speed",
}),
@@ -33,7 +33,13 @@ type Acc = {
plugin_origins: ConfigPlugin.Origin[]
}
export type Resolved = Omit<Info, "keybinds" | "leader_timeout"> & {
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout"> & {
attention: {
enabled: boolean
notifications: boolean
sound: boolean
volume: number
}
keybinds: TuiKeybind.BindingLookupView
leader_timeout: number
// Internal resolved plugin list used by runtime loading.
@@ -197,6 +203,12 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
const parsedKeybinds = TuiKeybind.parse(keybinds)
const result: Resolved = {
...acc.result,
attention: {
enabled: acc.result.attention?.enabled ?? true,
notifications: acc.result.attention?.notifications ?? true,
sound: acc.result.attention?.sound ?? true,
volume: acc.result.attention?.volume ?? 0.4,
},
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), {
commandMap: TuiKeybind.CommandMap,
bindingDefaults: TuiKeybind.bindingDefaults(),
@@ -40,6 +40,7 @@ type Input = {
theme: ReturnType<typeof useTheme>
toast: ReturnType<typeof useToast>
renderer: TuiPluginApi["renderer"]
attention: TuiPluginApi["attention"]
}
function routeRegister(routes: RouteMap, list: TuiRouteDefinition[], bump: () => void) {
@@ -203,6 +204,7 @@ export function createTuiApi(input: Input): TuiPluginApi {
}
return {
app: appApi(),
attention: input.attention,
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds),
keys: {
@@ -576,6 +576,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
return {
app: api.app,
attention: api.attention,
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds),
keys: api.keys,