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
+20
View File
@@ -29,6 +29,12 @@ Example:
"plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]],
"plugin_enabled": {
"acme.demo": false
},
"attention": {
"enabled": true,
"notifications": true,
"sound": true,
"volume": 0.4
}
}
```
@@ -45,6 +51,9 @@ Example:
- Internal plugins can declare `enabled: false` to be registered but inactive by default; `plugin_enabled` and runtime KV can still enable them by id.
- `plugin_enabled` is merged across config layers.
- Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup.
- `attention.enabled` disables all `api.attention.notify(...)` delivery when set to `false`.
- `attention.notifications` and `attention.sound` independently control terminal-mediated desktop notifications and built-in sounds.
- `attention.volume` sets the default built-in sound volume from `0` to `1`.
- `leader_timeout` is a top-level TUI setting.
- `keybinds` is a flat object keyed by command id; values are key binding values (`false`, `"none"`, a key string/object, a binding object, or an array of key strings/objects/binding objects).
- `keybinds.leader` sets the key used by `<leader>` shortcuts.
@@ -212,6 +221,7 @@ That is what makes local config-scoped plugins able to import `@opencode-ai/plug
Top-level API groups exposed to `tui(api, options, meta)`:
- `api.app.version`
- `api.attention.notify(input)`
- `api.keys.formatSequence(parts)`, `formatBindings(bindings)`
- `api.keymap`
- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
@@ -246,6 +256,16 @@ Top-level API groups exposed to `tui(api, options, meta)`:
- `formatBindings(bindings)` formats binding lists and returns `undefined` when there is nothing to show.
- For generic config-to-bindings helpers, import `createBindingLookup` from `@opencode-ai/plugin/tui`.
### Attention
- `api.attention.notify({ title?, message, sound?, when? })` requests user attention while keeping terminal focus, notifications, and audio owned by the host.
- `message` is required; `title` defaults to `"opencode"`; `when` defaults to `"blurred"`; `sound` defaults to `false`.
- `when: "blurred"` is the only supported mode. Calls are skipped while the terminal is focused or before any focus/blur event has been observed.
- The host strips ANSI/control characters and collapses newlines before sending text to the terminal notification API.
- `sound: true` plays the built-in attention sound at `attention.volume`; `sound: { volume }` overrides it for that call; `sound: { enabled: false }` disables sound for that call.
- Terminal and OS settings decide whether a requested notification is visibly displayed.
- Prefer privacy-safe messages such as `"A question needs your input"`; avoid full commands, paths, prompts, errors, secrets, or file contents unless the plugin intentionally exposes them.
### Routes
- Reserved route names: `home` and `session`.
+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,
@@ -0,0 +1,320 @@
import { describe, expect, test } from "bun:test"
import type { AudioErrorContext, AudioSound } from "@opentui/core"
import { createTuiAttention } from "@/cli/cmd/tui/attention"
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
type FocusEvent = "focus" | "blur"
type AttentionConfig = Pick<TuiConfig.Resolved, "attention">
class FakeRenderer {
isDestroyed = false
notificationResult = true
notificationThrows = false
notifications: { message: string; title: string | undefined }[] = []
listeners: Record<FocusEvent, Set<() => void>> = {
focus: new Set(),
blur: new Set(),
}
on(event: FocusEvent, listener: () => void) {
this.listeners[event].add(listener)
return this
}
off(event: FocusEvent, listener: () => void) {
this.listeners[event].delete(listener)
return this
}
emit(event: FocusEvent) {
for (const listener of this.listeners[event]) listener()
}
listenerCount(event: FocusEvent) {
return this.listeners[event].size
}
triggerNotification(message: string, title?: string) {
if (this.notificationThrows) throw new Error("notification failed")
this.notifications.push({ message, title })
return this.notificationResult
}
}
class FakeAudioEngine {
started = false
startResult = true
loadResult: AudioSound | null = 1
playResult: number | null = 1
startCalls = 0
startMixerCalls = 0
loadCalls = 0
playCalls = 0
disposeCalls = 0
volumes: (number | undefined)[] = []
errorListenerCount = 0
on(_event: "error", _listener: (error: Error, context: AudioErrorContext) => void) {
this.errorListenerCount += 1
return this
}
isStarted() {
return this.started
}
start() {
this.startCalls += 1
this.started = this.startResult
return this.startResult
}
startMixer() {
this.startMixerCalls += 1
return true
}
loadSound(_data: Uint8Array | ArrayBuffer) {
this.loadCalls += 1
return this.loadResult
}
play(_sound: AudioSound, options?: { volume?: number }) {
this.playCalls += 1
this.volumes.push(options?.volume)
return this.playResult
}
dispose() {
this.disposeCalls += 1
}
}
class FakeAudio {
engine = new FakeAudioEngine()
createCalls = 0
bytesCalls = 0
rejectBytes = false
create() {
this.createCalls += 1
return this.engine
}
async bytes() {
this.bytesCalls += 1
if (this.rejectBytes) throw new Error("decode failed")
return new Uint8Array([1, 2, 3])
}
}
function config(attention: Partial<AttentionConfig["attention"]> = {}): AttentionConfig {
return {
attention: {
enabled: true,
notifications: true,
sound: true,
volume: 0.4,
...attention,
},
}
}
describe("createTuiAttention", () => {
test("skips blurred-only requests until focus is known blurred", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudio()
const attention = createTuiAttention({ renderer, config: config(), audio })
expect(await attention.notify({ message: "hello", sound: true })).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "focus_unknown",
})
expect(renderer.notifications).toHaveLength(0)
expect(audio.createCalls).toBe(0)
})
test("skips focused requests", async () => {
const renderer = new FakeRenderer()
const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudio() })
renderer.emit("focus")
expect(await attention.notify({ message: "hello", sound: true })).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "focused",
})
expect(renderer.notifications).toHaveLength(0)
})
test("notifies while blurred", async () => {
const renderer = new FakeRenderer()
const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudio() })
renderer.emit("blur")
expect(await attention.notify({ title: "opencode", message: "hello" })).toEqual({
ok: true,
notification: true,
sound: false,
})
expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello" }])
})
test("skips empty messages and disabled attention", async () => {
const empty = new FakeRenderer()
empty.emit("blur")
const disabled = new FakeRenderer()
disabled.emit("blur")
expect(await createTuiAttention({ renderer: empty, config: config() }).notify({ message: " \n " })).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "empty_message",
})
expect(
await createTuiAttention({ renderer: disabled, config: config({ enabled: false }) }).notify({ message: "hello" }),
).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "attention_disabled",
})
})
test("respects notification and sound config independently", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudio()
const attention = createTuiAttention({ renderer, config: config({ notifications: false }), audio })
renderer.emit("blur")
expect(await attention.notify({ message: "hello", sound: true })).toEqual({
ok: true,
notification: false,
sound: true,
})
expect(renderer.notifications).toHaveLength(0)
expect(audio.engine.playCalls).toBe(1)
const soundDisabledRenderer = new FakeRenderer()
const soundDisabledAudio = new FakeAudio()
const soundDisabled = createTuiAttention({
renderer: soundDisabledRenderer,
config: config({ sound: false }),
audio: soundDisabledAudio,
})
soundDisabledRenderer.emit("blur")
expect(await soundDisabled.notify({ message: "hello", sound: true })).toEqual({
ok: true,
notification: true,
sound: false,
})
expect(soundDisabledAudio.createCalls).toBe(0)
})
test("initializes audio lazily only for eligible sound requests", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudio()
const attention = createTuiAttention({ renderer, config: config(), audio })
await attention.notify({ message: "unknown", sound: true })
expect(audio.createCalls).toBe(0)
renderer.emit("blur")
expect(await attention.notify({ message: "blurred", sound: { volume: 2 } })).toEqual({
ok: true,
notification: true,
sound: true,
})
expect(audio.createCalls).toBe(1)
expect(audio.engine.startCalls).toBe(1)
expect(audio.engine.startMixerCalls).toBe(0)
expect(audio.engine.loadCalls).toBe(1)
expect(audio.engine.volumes).toEqual([1])
})
test("handles unavailable or already-started audio correctly", async () => {
const unavailableRenderer = new FakeRenderer()
const unavailableAudio = new FakeAudio()
unavailableAudio.engine.startResult = false
const unavailable = createTuiAttention({ renderer: unavailableRenderer, config: config(), audio: unavailableAudio })
unavailableRenderer.emit("blur")
expect(await unavailable.notify({ message: "hello", sound: true })).toEqual({
ok: true,
notification: true,
sound: false,
})
expect(unavailableAudio.engine.loadCalls).toBe(0)
expect(unavailableAudio.engine.playCalls).toBe(0)
const startedRenderer = new FakeRenderer()
const startedAudio = new FakeAudio()
startedAudio.engine.started = true
const started = createTuiAttention({ renderer: startedRenderer, config: config(), audio: startedAudio })
startedRenderer.emit("blur")
await started.notify({ message: "one", sound: true })
await started.notify({ message: "two", sound: true })
expect(startedAudio.engine.startCalls).toBe(0)
expect(startedAudio.engine.loadCalls).toBe(1)
expect(startedAudio.engine.playCalls).toBe(2)
})
test("does not throw for notification or sound failures", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudio()
renderer.notificationThrows = true
audio.rejectBytes = true
const attention = createTuiAttention({ renderer, config: config(), audio })
renderer.emit("blur")
expect(await attention.notify({ message: "hello", sound: true })).toEqual({
ok: false,
notification: false,
sound: false,
})
})
test("strips unsafe notification text", async () => {
const renderer = new FakeRenderer()
const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudio() })
renderer.emit("blur")
await attention.notify({
title: "\u001b[31m danger\n title\u0007",
message: "\u001b[32m hello\n world\u0000",
})
expect(renderer.notifications).toEqual([{ title: "danger title", message: "hello world" }])
})
test("disposes renderer listeners and audio", async () => {
const renderer = new FakeRenderer()
const audio = new FakeAudio()
const attention = createTuiAttention({ renderer, config: config(), audio })
renderer.emit("blur")
await attention.notify({ message: "hello", sound: true })
expect(renderer.listenerCount("focus")).toBe(1)
expect(renderer.listenerCount("blur")).toBe(1)
attention.dispose()
renderer.isDestroyed = true
expect(renderer.listenerCount("focus")).toBe(0)
expect(renderer.listenerCount("blur")).toBe(0)
expect(audio.engine.disposeCalls).toBe(1)
expect(await attention.notify({ message: "hello" })).toEqual({
ok: false,
notification: false,
sound: false,
skipped: "renderer_destroyed",
})
})
})
@@ -93,6 +93,12 @@ function config(input?: {
...(bind?.inputNewline && { input_newline: bind.inputNewline }),
})
return {
attention: {
enabled: true,
notifications: true,
sound: true,
volume: 0.4,
},
diff_style: input?.diff_style,
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
commandMap: TuiKeybind.CommandMap,
+26
View File
@@ -142,6 +142,32 @@ test("loads tui config with the same precedence order as server config paths", a
expect(config.diff_style).toBe("stacked")
})
test("resolves attention config defaults and overrides", async () => {
await using defaults = await tmpdir()
expect((await getTuiConfig(defaults.path)).attention).toEqual({
enabled: true,
notifications: true,
sound: true,
volume: 0.4,
})
await using overridden = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({ attention: { enabled: false, notifications: false, sound: false, volume: 0.7 } }, null, 2),
)
},
})
expect((await getTuiConfig(overridden.path)).attention).toEqual({
enabled: false,
notifications: false,
sound: false,
volume: 0.7,
})
})
test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -83,6 +83,7 @@ function themeCurrent(): HostPluginApi["theme"]["current"] {
type Opts = {
client?: HostPluginApi["client"] | (() => HostPluginApi["client"])
renderer?: HostPluginApi["renderer"]
attention?: HostPluginApi["attention"]
count?: Count
keymap?: HostPluginApi["keymap"]
tuiConfig?: Partial<HostPluginApi["tuiConfig"]>
@@ -183,6 +184,17 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
return opts.app?.version ?? "0.0.0-test"
},
},
attention:
opts.attention ??
{
async notify() {
return {
ok: false,
notification: false,
sound: false,
}
},
},
keys: {
formatSequence: () => "",
formatBindings: () => undefined,
@@ -5,7 +5,8 @@ import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
import { TuiKeybind } from "../../src/cli/cmd/tui/config/keybind"
type PluginSpec = string | [string, Record<string, unknown>]
type ResolvedInput = Omit<TuiConfig.Resolved, "keybinds" | "leader_timeout"> & {
type ResolvedInput = Omit<TuiConfig.Resolved, "attention" | "keybinds" | "leader_timeout"> & {
attention?: Partial<TuiConfig.Resolved["attention"]>
keybinds?: Partial<TuiKeybind.Keybinds>
leader_timeout?: number
}
@@ -22,6 +23,13 @@ export function createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Re
const keybinds = TuiKeybind.Keybinds.parse(input.keybinds ?? {})
return {
...input,
attention: {
enabled: true,
notifications: true,
sound: true,
volume: 0.4,
...input.attention,
},
keybinds: createTuiResolvedKeybinds(keybinds),
leader_timeout: input.leader_timeout ?? 2000,
}
+43
View File
@@ -225,6 +225,40 @@ export type TuiToast = {
duration?: number
}
export type TuiAttentionWhen = "blurred"
export type TuiAttentionSound =
| boolean
| {
enabled?: boolean
volume?: number
}
export type TuiAttentionNotifyInput = {
title?: string
message: string
sound?: TuiAttentionSound
when?: TuiAttentionWhen
}
export type TuiAttentionNotifySkipReason =
| "attention_disabled"
| "empty_message"
| "focused"
| "focus_unknown"
| "renderer_destroyed"
export type TuiAttentionNotifyResult = {
ok: boolean
notification: boolean
sound: boolean
skipped?: TuiAttentionNotifySkipReason
}
export type TuiAttention = {
notify(input: TuiAttentionNotifyInput): Promise<TuiAttentionNotifyResult>
}
export type TuiThemeCurrent = {
readonly primary: RGBA
readonly secondary: RGBA
@@ -331,9 +365,17 @@ type TuiBindingLookupView = {
omit: (name: string, commands: readonly string[]) => Binding<Renderable, KeyEvent>[]
}
type TuiAttentionConfigView = {
enabled: boolean
notifications: boolean
sound: boolean
volume: number
}
type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "plugin"> &
NonNullable<PluginConfig["tui"]> & {
leader_timeout: number
attention: TuiAttentionConfigView
plugin_enabled?: Record<string, boolean>
keybinds: TuiBindingLookupView
}
@@ -497,6 +539,7 @@ export type TuiWorkspace = {
export type TuiPluginApi = {
app: TuiApp
attention: TuiAttention
/**
* Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2.
*