attention api
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user