attention.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. /// <reference path="./audio.d.ts" />
  2. import type {
  3. TuiAttention,
  4. TuiAttentionNotifyInput,
  5. TuiAttentionNotifyResult,
  6. TuiAttentionNotifySkipReason,
  7. TuiAttentionWhen,
  8. TuiKV,
  9. TuiAttentionSoundName,
  10. TuiAttentionSoundPack,
  11. TuiAttentionSoundPackInfo,
  12. } from "@opencode-ai/plugin/tui"
  13. import { AttentionSoundName, type TuiConfig } from "./config"
  14. import { Schema } from "effect"
  15. import stripAnsi from "strip-ansi"
  16. import * as TuiAudio from "./audio"
  17. import defaultSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
  18. import questionSoundPath from "@opencode-ai/ui/audio/bip-bop-03.mp3" with { type: "file" }
  19. import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with { type: "file" }
  20. import errorSoundPath from "@opencode-ai/ui/audio/nope-03.mp3" with { type: "file" }
  21. import doneSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
  22. import subagentDoneSoundPath from "@opencode-ai/ui/audio/yup-01.mp3" with { type: "file" }
  23. type FocusState = "unknown" | "focused" | "blurred"
  24. type AttentionRenderer = {
  25. readonly isDestroyed: boolean
  26. on(event: "focus" | "blur", listener: () => void): unknown
  27. off(event: "focus" | "blur", listener: () => void): unknown
  28. triggerNotification(message: string, title?: string): boolean
  29. }
  30. type RegisteredSoundPack = TuiAttentionSoundPack & {
  31. builtin: boolean
  32. }
  33. type TuiAttentionHost = TuiAttention & {
  34. dispose(): void
  35. }
  36. const DEFAULT_TITLE = "opencode"
  37. const DEFAULT_PACK_ID = "opencode.default"
  38. const KV_SOUND_PACK = "attention_sound_pack"
  39. const TITLE_LIMIT = 80
  40. const MESSAGE_LIMIT = 240
  41. const BUILTIN_PACK: RegisteredSoundPack = {
  42. id: DEFAULT_PACK_ID,
  43. name: "OpenCode Default",
  44. builtin: true,
  45. sounds: {
  46. default: defaultSoundPath,
  47. question: questionSoundPath,
  48. permission: permissionSoundPath,
  49. error: errorSoundPath,
  50. done: doneSoundPath,
  51. subagent_done: subagentDoneSoundPath,
  52. },
  53. }
  54. function skipped(reason: TuiAttentionNotifySkipReason): TuiAttentionNotifyResult {
  55. return {
  56. ok: false,
  57. notification: false,
  58. sound: false,
  59. skipped: reason,
  60. }
  61. }
  62. function normalizeText(input: string | undefined, fallback: string, limit: number) {
  63. const text = stripAnsi(input ?? "")
  64. .replace(/[ \t]*[\r\n]+[ \t]*/g, " ")
  65. .replace(/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, "")
  66. .trim()
  67. const normalized = text.length ? text : fallback
  68. return Array.from(normalized).slice(0, limit).join("")
  69. }
  70. function clampVolume(volume: number) {
  71. if (!Number.isFinite(volume)) return 0
  72. return Math.min(1, Math.max(0, volume))
  73. }
  74. function soundVolume(input: TuiAttentionNotifyInput, config: Pick<TuiConfig.Resolved, "attention">) {
  75. if (!config.attention.sound) return
  76. if (input.sound === false) return
  77. if (input.sound === undefined) return clampVolume(config.attention.volume)
  78. if (input.sound === true) return clampVolume(config.attention.volume)
  79. return clampVolume(input.sound.volume ?? config.attention.volume)
  80. }
  81. function normalizePack(pack: TuiAttentionSoundPack): RegisteredSoundPack | undefined {
  82. const id = pack.id.trim()
  83. if (!id) return
  84. return {
  85. id,
  86. name: pack.name?.trim() || undefined,
  87. builtin: false,
  88. sounds: Object.fromEntries(
  89. Object.entries(pack.sounds).filter(
  90. (item): item is [TuiAttentionSoundName, string] =>
  91. Schema.is(AttentionSoundName)(item[0]) && typeof item[1] === "string" && item[1].trim().length > 0,
  92. ),
  93. ),
  94. }
  95. }
  96. function focusSkip(when: TuiAttentionWhen, focus: FocusState) {
  97. if (when === "always") return
  98. if (focus === "unknown") return "focus_unknown"
  99. if (when === "blurred" && focus === "focused") return "focused"
  100. if (when === "focused" && focus === "blurred") return "blurred"
  101. }
  102. export function createTuiAttention(input: {
  103. renderer: AttentionRenderer
  104. config: Pick<TuiConfig.Resolved, "attention">
  105. kv?: TuiKV
  106. audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
  107. }): TuiAttentionHost {
  108. let focus: FocusState = "unknown"
  109. let disposed = false
  110. let activePackID: string | undefined
  111. const packs = new Map<string, RegisteredSoundPack>([[BUILTIN_PACK.id, BUILTIN_PACK]])
  112. const audio = input.audio ?? TuiAudio
  113. const onFocus = () => {
  114. focus = "focused"
  115. }
  116. const onBlur = () => {
  117. focus = "blurred"
  118. }
  119. input.renderer.on("focus", onFocus)
  120. input.renderer.on("blur", onBlur)
  121. function configuredPackID() {
  122. const stored = input.kv?.get<string | undefined>(KV_SOUND_PACK, undefined)
  123. return activePackID ?? stored ?? input.config.attention.sound_pack
  124. }
  125. function currentPack() {
  126. return packs.get(configuredPackID()) ?? BUILTIN_PACK
  127. }
  128. function soundCandidates(name: TuiAttentionSoundName) {
  129. return [input.config.attention.sounds[name], currentPack().sounds[name], BUILTIN_PACK.sounds[name]].filter(
  130. (item, index, list): item is string => typeof item === "string" && list.indexOf(item) === index,
  131. )
  132. }
  133. async function playSound(name: TuiAttentionSoundName, volume: number) {
  134. try {
  135. for (const file of soundCandidates(name)) {
  136. const current = await audio.loadSoundFile(file).catch((error) => {
  137. console.debug("failed to load attention sound", { file, error })
  138. return null
  139. })
  140. if (disposed) return false
  141. if (current == null) continue
  142. if (audio.play(current, { volume }) != null) return true
  143. }
  144. return false
  145. } catch (error) {
  146. console.debug("failed to play attention sound", { error })
  147. return false
  148. }
  149. }
  150. return {
  151. async notify(request) {
  152. try {
  153. if (!input.config.attention.enabled) return skipped("attention_disabled")
  154. if (disposed || input.renderer.isDestroyed) return skipped("renderer_destroyed")
  155. const message = normalizeText(request.message, "", MESSAGE_LIMIT)
  156. if (!message) return skipped("empty_message")
  157. const requestedNotification = typeof request.notification === "object" ? request.notification : undefined
  158. const notificationSkip = focusSkip(requestedNotification?.when ?? "blurred", focus)
  159. const notificationRequested = input.config.attention.notifications && request.notification !== false
  160. const shouldNotify = notificationRequested && !notificationSkip
  161. const notification = shouldNotify
  162. ? (() => {
  163. try {
  164. return input.renderer.triggerNotification(
  165. message,
  166. normalizeText(request.title, DEFAULT_TITLE, TITLE_LIMIT),
  167. )
  168. } catch (error) {
  169. console.debug("failed to trigger attention notification", { error })
  170. return false
  171. }
  172. })()
  173. : false
  174. const volume = soundVolume(request, input.config)
  175. const requestedSound = typeof request.sound === "object" ? request.sound : undefined
  176. const soundSkip = volume === undefined ? undefined : focusSkip(requestedSound?.when ?? "always", focus)
  177. const soundName =
  178. requestedSound?.name && Schema.is(AttentionSoundName)(requestedSound.name) ? requestedSound.name : "default"
  179. const sound = volume === undefined || soundSkip ? false : await playSound(soundName, volume)
  180. if (!notification && !sound) {
  181. if (notificationRequested && notificationSkip) return skipped(notificationSkip)
  182. if (soundSkip) return skipped(soundSkip)
  183. }
  184. return {
  185. ok: notification || sound,
  186. notification,
  187. sound,
  188. }
  189. } catch (error) {
  190. console.debug("failed to handle attention notification", { error })
  191. return {
  192. ok: false,
  193. notification: false,
  194. sound: false,
  195. }
  196. }
  197. },
  198. soundboard: {
  199. registerPack(pack) {
  200. const next = normalizePack(pack)
  201. if (!next) return () => {}
  202. packs.set(next.id, next)
  203. let disposed = false
  204. return () => {
  205. if (disposed) return
  206. disposed = true
  207. if (packs.get(next.id) === next) packs.delete(next.id)
  208. }
  209. },
  210. activate(id, options) {
  211. const pack = packs.get(id)
  212. if (!pack) return false
  213. activePackID = pack.id
  214. if (options?.persist) input.kv?.set(KV_SOUND_PACK, pack.id)
  215. return true
  216. },
  217. current() {
  218. return currentPack().id
  219. },
  220. list(): TuiAttentionSoundPackInfo[] {
  221. const current = currentPack().id
  222. return Array.from(packs.values()).map((pack) => ({
  223. id: pack.id,
  224. name: pack.name,
  225. active: pack.id === current,
  226. builtin: pack.builtin,
  227. }))
  228. },
  229. },
  230. dispose() {
  231. if (disposed) return
  232. disposed = true
  233. input.renderer.off("focus", onFocus)
  234. input.renderer.off("blur", onBlur)
  235. },
  236. }
  237. }