@neurocode-ai/tui 1.18.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/bunfig.toml +4 -0
  2. package/package.json +72 -0
  3. package/src/app.tsx +1134 -0
  4. package/src/attention.ts +260 -0
  5. package/src/audio.d.ts +9 -0
  6. package/src/audio.ts +53 -0
  7. package/src/clipboard.ts +124 -0
  8. package/src/component/bg-pulse-render.ts +436 -0
  9. package/src/component/bg-pulse.tsx +99 -0
  10. package/src/component/command-palette.tsx +79 -0
  11. package/src/component/dialog-agent.tsx +31 -0
  12. package/src/component/dialog-console-org.tsx +135 -0
  13. package/src/component/dialog-debug.tsx +90 -0
  14. package/src/component/dialog-mcp.tsx +85 -0
  15. package/src/component/dialog-model.tsx +197 -0
  16. package/src/component/dialog-move-session.tsx +353 -0
  17. package/src/component/dialog-provider.tsx +469 -0
  18. package/src/component/dialog-retry-action.tsx +160 -0
  19. package/src/component/dialog-session-delete-failed.tsx +99 -0
  20. package/src/component/dialog-session-list.tsx +364 -0
  21. package/src/component/dialog-session-rename.tsx +31 -0
  22. package/src/component/dialog-skill.tsx +70 -0
  23. package/src/component/dialog-stash.tsx +87 -0
  24. package/src/component/dialog-status.tsx +168 -0
  25. package/src/component/dialog-tag.tsx +47 -0
  26. package/src/component/dialog-theme-list.tsx +50 -0
  27. package/src/component/dialog-variant.tsx +39 -0
  28. package/src/component/dialog-workspace-create.tsx +308 -0
  29. package/src/component/dialog-workspace-file-changes.tsx +144 -0
  30. package/src/component/dialog-workspace-list.tsx +112 -0
  31. package/src/component/dialog-workspace-unavailable.tsx +69 -0
  32. package/src/component/error-component.tsx +240 -0
  33. package/src/component/logo.tsx +61 -0
  34. package/src/component/plugin-route-missing.tsx +14 -0
  35. package/src/component/prompt/autocomplete.tsx +781 -0
  36. package/src/component/prompt/cwd.ts +0 -0
  37. package/src/component/prompt/frecency.tsx +1 -0
  38. package/src/component/prompt/history.tsx +1 -0
  39. package/src/component/prompt/index.tsx +1713 -0
  40. package/src/component/prompt/local-attachment.ts +48 -0
  41. package/src/component/prompt/move.tsx +205 -0
  42. package/src/component/prompt/stash.tsx +1 -0
  43. package/src/component/prompt/workspace.tsx +137 -0
  44. package/src/component/register-spinner.ts +6 -0
  45. package/src/component/spinner.tsx +26 -0
  46. package/src/component/startup-loading.tsx +63 -0
  47. package/src/component/todo-item.tsx +32 -0
  48. package/src/component/use-connected.tsx +12 -0
  49. package/src/component/workspace-label.tsx +19 -0
  50. package/src/config/index.tsx +129 -0
  51. package/src/config/keybind.ts +471 -0
  52. package/src/context/args.tsx +16 -0
  53. package/src/context/clipboard.tsx +18 -0
  54. package/src/context/data.tsx +569 -0
  55. package/src/context/directory.ts +17 -0
  56. package/src/context/editor.ts +408 -0
  57. package/src/context/epilogue.tsx +6 -0
  58. package/src/context/event.ts +36 -0
  59. package/src/context/exit.tsx +8 -0
  60. package/src/context/helper.tsx +26 -0
  61. package/src/context/kv.tsx +66 -0
  62. package/src/context/local.tsx +542 -0
  63. package/src/context/location.tsx +14 -0
  64. package/src/context/path-format.tsx +24 -0
  65. package/src/context/permission.tsx +26 -0
  66. package/src/context/project.tsx +115 -0
  67. package/src/context/prompt.tsx +18 -0
  68. package/src/context/route.tsx +60 -0
  69. package/src/context/runtime.tsx +62 -0
  70. package/src/context/sdk.tsx +151 -0
  71. package/src/context/sync.tsx +666 -0
  72. package/src/context/theme.tsx +332 -0
  73. package/src/context/thinking.ts +67 -0
  74. package/src/editor-zed.ts +286 -0
  75. package/src/editor.ts +101 -0
  76. package/src/feature-plugins/builtins.ts +36 -0
  77. package/src/feature-plugins/home/footer.tsx +100 -0
  78. package/src/feature-plugins/home/tips-view.tsx +287 -0
  79. package/src/feature-plugins/home/tips.tsx +59 -0
  80. package/src/feature-plugins/sidebar/context.tsx +65 -0
  81. package/src/feature-plugins/sidebar/files.tsx +70 -0
  82. package/src/feature-plugins/sidebar/footer.tsx +98 -0
  83. package/src/feature-plugins/sidebar/lsp.tsx +65 -0
  84. package/src/feature-plugins/sidebar/mcp.tsx +97 -0
  85. package/src/feature-plugins/sidebar/todo.tsx +49 -0
  86. package/src/feature-plugins/system/diff-viewer-file-tree-utils.ts +232 -0
  87. package/src/feature-plugins/system/diff-viewer-file-tree.tsx +162 -0
  88. package/src/feature-plugins/system/diff-viewer-ui.tsx +103 -0
  89. package/src/feature-plugins/system/diff-viewer.tsx +1077 -0
  90. package/src/feature-plugins/system/notifications.ts +94 -0
  91. package/src/feature-plugins/system/plugins.tsx +269 -0
  92. package/src/feature-plugins/system/which-key.tsx +608 -0
  93. package/src/index.tsx +1 -0
  94. package/src/keymap.tsx +290 -0
  95. package/src/logo.ts +11 -0
  96. package/src/parsers-config.ts +386 -0
  97. package/src/plugin/adapters.tsx +355 -0
  98. package/src/plugin/api.ts +52 -0
  99. package/src/plugin/command-shim.ts +109 -0
  100. package/src/plugin/runtime.tsx +81 -0
  101. package/src/plugin/slots.tsx +65 -0
  102. package/src/prompt/display.ts +48 -0
  103. package/src/prompt/frecency.tsx +80 -0
  104. package/src/prompt/history.tsx +111 -0
  105. package/src/prompt/part.ts +29 -0
  106. package/src/prompt/stash.tsx +89 -0
  107. package/src/prompt/traits.ts +29 -0
  108. package/src/routes/home/session-destination.tsx +41 -0
  109. package/src/routes/home.tsx +95 -0
  110. package/src/routes/session/dialog-fork-from-timeline.tsx +76 -0
  111. package/src/routes/session/dialog-message.tsx +109 -0
  112. package/src/routes/session/dialog-subagent.tsx +26 -0
  113. package/src/routes/session/dialog-timeline.tsx +47 -0
  114. package/src/routes/session/footer.tsx +91 -0
  115. package/src/routes/session/index.tsx +2710 -0
  116. package/src/routes/session/permission.tsx +718 -0
  117. package/src/routes/session/question.tsx +514 -0
  118. package/src/routes/session/sidebar.tsx +103 -0
  119. package/src/routes/session/subagent-footer.tsx +132 -0
  120. package/src/runtime.tsx +9 -0
  121. package/src/terminal-win32.ts +130 -0
  122. package/src/theme/assets/aura.json +69 -0
  123. package/src/theme/assets/ayu.json +80 -0
  124. package/src/theme/assets/carbonfox.json +248 -0
  125. package/src/theme/assets/catppuccin-frappe.json +230 -0
  126. package/src/theme/assets/catppuccin-macchiato.json +230 -0
  127. package/src/theme/assets/catppuccin.json +112 -0
  128. package/src/theme/assets/cobalt2.json +225 -0
  129. package/src/theme/assets/cursor.json +249 -0
  130. package/src/theme/assets/dracula.json +219 -0
  131. package/src/theme/assets/everforest.json +241 -0
  132. package/src/theme/assets/flexoki.json +237 -0
  133. package/src/theme/assets/github.json +233 -0
  134. package/src/theme/assets/gruvbox.json +242 -0
  135. package/src/theme/assets/kanagawa.json +77 -0
  136. package/src/theme/assets/lucent-orng.json +234 -0
  137. package/src/theme/assets/material.json +235 -0
  138. package/src/theme/assets/matrix.json +77 -0
  139. package/src/theme/assets/mercury.json +252 -0
  140. package/src/theme/assets/monokai.json +221 -0
  141. package/src/theme/assets/nightowl.json +221 -0
  142. package/src/theme/assets/nord.json +223 -0
  143. package/src/theme/assets/one-dark.json +84 -0
  144. package/src/theme/assets/opencode.json +245 -0
  145. package/src/theme/assets/orng.json +249 -0
  146. package/src/theme/assets/osaka-jade.json +93 -0
  147. package/src/theme/assets/palenight.json +222 -0
  148. package/src/theme/assets/rosepine.json +234 -0
  149. package/src/theme/assets/solarized.json +223 -0
  150. package/src/theme/assets/synthwave84.json +226 -0
  151. package/src/theme/assets/tokyonight.json +243 -0
  152. package/src/theme/assets/vercel.json +245 -0
  153. package/src/theme/assets/vesper.json +218 -0
  154. package/src/theme/assets/zenburn.json +223 -0
  155. package/src/theme/index.ts +1089 -0
  156. package/src/ui/border.ts +21 -0
  157. package/src/ui/dialog-alert.tsx +66 -0
  158. package/src/ui/dialog-confirm.tsx +108 -0
  159. package/src/ui/dialog-export-options.tsx +217 -0
  160. package/src/ui/dialog-help.tsx +40 -0
  161. package/src/ui/dialog-prompt.tsx +126 -0
  162. package/src/ui/dialog-select.tsx +790 -0
  163. package/src/ui/dialog.tsx +231 -0
  164. package/src/ui/link.tsx +34 -0
  165. package/src/ui/spinner.ts +368 -0
  166. package/src/ui/toast.tsx +102 -0
  167. package/src/util/collapse-tool-output.ts +19 -0
  168. package/src/util/error.ts +182 -0
  169. package/src/util/filetype.ts +130 -0
  170. package/src/util/format.ts +20 -0
  171. package/src/util/layout.ts +25 -0
  172. package/src/util/locale.ts +86 -0
  173. package/src/util/model.ts +28 -0
  174. package/src/util/path.ts +12 -0
  175. package/src/util/persistence.ts +33 -0
  176. package/src/util/presentation.ts +38 -0
  177. package/src/util/provider-origin.ts +7 -0
  178. package/src/util/record.ts +3 -0
  179. package/src/util/renderer.ts +7 -0
  180. package/src/util/revert-diff.ts +18 -0
  181. package/src/util/scroll.ts +27 -0
  182. package/src/util/selection.ts +79 -0
  183. package/src/util/session.ts +3 -0
  184. package/src/util/signal.ts +51 -0
  185. package/src/util/system.ts +20 -0
  186. package/src/util/tool-display.ts +13 -0
  187. package/src/util/transcript.ts +112 -0
  188. package/sst-env.d.ts +10 -0
  189. package/test/app-lifecycle.test.tsx +128 -0
  190. package/test/cli/cmd/tui/dialog-workspace-create.test.ts +28 -0
  191. package/test/cli/cmd/tui/model-options.test.ts +32 -0
  192. package/test/cli/cmd/tui/notifications.test.ts +267 -0
  193. package/test/cli/cmd/tui/provider-options.test.ts +41 -0
  194. package/test/cli/cmd/tui/sync-fixture.tsx +70 -0
  195. package/test/cli/cmd/tui/sync-live-hydration.test.tsx +262 -0
  196. package/test/cli/cmd/tui/sync-undefined-messages.test.tsx +43 -0
  197. package/test/cli/cmd/tui/sync.test.tsx +65 -0
  198. package/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +92 -0
  199. package/test/cli/tui/data.test.tsx +486 -0
  200. package/test/cli/tui/dialog-prompt.test.tsx +147 -0
  201. package/test/cli/tui/diff-viewer-file-tree.test.tsx +200 -0
  202. package/test/cli/tui/diff-viewer.test.tsx +268 -0
  203. package/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +351 -0
  204. package/test/cli/tui/prompt-submit-race.test.ts +98 -0
  205. package/test/cli/tui/thinking.test.ts +36 -0
  206. package/test/cli/tui/use-event.test.tsx +148 -0
  207. package/test/clipboard.test.ts +19 -0
  208. package/test/component/dialog-session-list.test.ts +46 -0
  209. package/test/config.test.tsx +127 -0
  210. package/test/context/local.test.ts +22 -0
  211. package/test/editor.test.ts +32 -0
  212. package/test/feature-plugins/diff-viewer-file-tree-utils.test.ts +323 -0
  213. package/test/fixture/fixture.ts +13 -0
  214. package/test/fixture/tui-environment.tsx +32 -0
  215. package/test/fixture/tui-plugin.ts +36 -0
  216. package/test/fixture/tui-runtime.ts +12 -0
  217. package/test/fixture/tui-sdk.ts +109 -0
  218. package/test/index.test.tsx +6 -0
  219. package/test/keymap.test.tsx +141 -0
  220. package/test/plugin/runtime.test.ts +50 -0
  221. package/test/plugin/slots.test.tsx +38 -0
  222. package/test/prompt/display.test.ts +33 -0
  223. package/test/prompt/history.test.ts +39 -0
  224. package/test/prompt/jsonl.test.ts +24 -0
  225. package/test/prompt/local-attachment.test.ts +43 -0
  226. package/test/prompt/part.test.ts +53 -0
  227. package/test/prompt/persistence.test.ts +23 -0
  228. package/test/prompt/traits.test.ts +25 -0
  229. package/test/runtime.test.tsx +37 -0
  230. package/test/theme.test.ts +81 -0
  231. package/test/util/error.test.ts +49 -0
  232. package/test/util/filetype.test.ts +16 -0
  233. package/test/util/format.test.ts +59 -0
  234. package/test/util/model.test.ts +9 -0
  235. package/test/util/presentation.test.ts +8 -0
  236. package/test/util/renderer.test.ts +30 -0
  237. package/test/util/revert-diff.test.ts +35 -0
  238. package/test/util/session.test.ts +10 -0
  239. package/test/util/tool-display.test.ts +40 -0
  240. package/test/util/transcript.test.ts +421 -0
  241. package/tsconfig.json +10 -0
@@ -0,0 +1,260 @@
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 "@neurocode-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 "@neurocode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
18
+ import questionSoundPath from "@neurocode-ai/ui/audio/bip-bop-03.mp3" with { type: "file" }
19
+ import permissionSoundPath from "@neurocode-ai/ui/audio/staplebops-06.mp3" with { type: "file" }
20
+ import errorSoundPath from "@neurocode-ai/ui/audio/nope-03.mp3" with { type: "file" }
21
+ import doneSoundPath from "@neurocode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
22
+ import subagentDoneSoundPath from "@neurocode-ai/ui/audio/yup-01.mp3" with { type: "file" }
23
+
24
+ type FocusState = "unknown" | "focused" | "blurred"
25
+
26
+ type AttentionRenderer = {
27
+ readonly isDestroyed: boolean
28
+ on(event: "focus" | "blur", listener: () => void): unknown
29
+ off(event: "focus" | "blur", listener: () => void): unknown
30
+ triggerNotification(message: string, title?: string): boolean
31
+ }
32
+
33
+ type RegisteredSoundPack = TuiAttentionSoundPack & {
34
+ builtin: boolean
35
+ }
36
+
37
+ type TuiAttentionHost = TuiAttention & {
38
+ dispose(): void
39
+ }
40
+
41
+ const DEFAULT_TITLE = "neurocode"
42
+ const DEFAULT_PACK_ID = "neurocode.default"
43
+ const KV_SOUND_PACK = "attention_sound_pack"
44
+ const TITLE_LIMIT = 80
45
+ const MESSAGE_LIMIT = 240
46
+ const BUILTIN_PACK: RegisteredSoundPack = {
47
+ id: DEFAULT_PACK_ID,
48
+ name: "NeuroCode Default",
49
+ builtin: true,
50
+ sounds: {
51
+ default: defaultSoundPath,
52
+ question: questionSoundPath,
53
+ permission: permissionSoundPath,
54
+ error: errorSoundPath,
55
+ done: doneSoundPath,
56
+ subagent_done: subagentDoneSoundPath,
57
+ },
58
+ }
59
+
60
+ function skipped(reason: TuiAttentionNotifySkipReason): TuiAttentionNotifyResult {
61
+ return {
62
+ ok: false,
63
+ notification: false,
64
+ sound: false,
65
+ skipped: reason,
66
+ }
67
+ }
68
+
69
+ function normalizeText(input: string | undefined, fallback: string, limit: number) {
70
+ const text = stripAnsi(input ?? "")
71
+ .replace(/[ \t]*[\r\n]+[ \t]*/g, " ")
72
+ .replace(/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, "")
73
+ .trim()
74
+ const normalized = text.length ? text : fallback
75
+ return Array.from(normalized).slice(0, limit).join("")
76
+ }
77
+
78
+ function clampVolume(volume: number) {
79
+ if (!Number.isFinite(volume)) return 0
80
+ return Math.min(1, Math.max(0, volume))
81
+ }
82
+
83
+ function soundVolume(input: TuiAttentionNotifyInput, config: Pick<TuiConfig.Resolved, "attention">) {
84
+ if (!config.attention.sound) return
85
+ if (input.sound === false) return
86
+ if (input.sound === undefined) return clampVolume(config.attention.volume)
87
+ if (input.sound === true) return clampVolume(config.attention.volume)
88
+ return clampVolume(input.sound.volume ?? config.attention.volume)
89
+ }
90
+
91
+ function normalizePack(pack: TuiAttentionSoundPack): RegisteredSoundPack | undefined {
92
+ const id = pack.id.trim()
93
+ if (!id) return
94
+ return {
95
+ id,
96
+ name: pack.name?.trim() || undefined,
97
+ builtin: false,
98
+ sounds: Object.fromEntries(
99
+ Object.entries(pack.sounds).filter(
100
+ (item): item is [TuiAttentionSoundName, string] =>
101
+ Schema.is(AttentionSoundName)(item[0]) && typeof item[1] === "string" && item[1].trim().length > 0,
102
+ ),
103
+ ),
104
+ }
105
+ }
106
+
107
+ function focusSkip(when: TuiAttentionWhen, focus: FocusState) {
108
+ if (when === "always") return
109
+ if (focus === "unknown") return "focus_unknown"
110
+ if (when === "blurred" && focus === "focused") return "focused"
111
+ if (when === "focused" && focus === "blurred") return "blurred"
112
+ }
113
+
114
+ export function createTuiAttention(input: {
115
+ renderer: AttentionRenderer
116
+ config: Pick<TuiConfig.Resolved, "attention">
117
+ kv?: TuiKV
118
+ audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
119
+ }): TuiAttentionHost {
120
+ let focus: FocusState = "unknown"
121
+ let disposed = false
122
+ let activePackID: string | undefined
123
+ const packs = new Map<string, RegisteredSoundPack>([[BUILTIN_PACK.id, BUILTIN_PACK]])
124
+ const audio = input.audio ?? TuiAudio
125
+
126
+ const onFocus = () => {
127
+ focus = "focused"
128
+ }
129
+ const onBlur = () => {
130
+ focus = "blurred"
131
+ }
132
+
133
+ input.renderer.on("focus", onFocus)
134
+ input.renderer.on("blur", onBlur)
135
+
136
+ function configuredPackID() {
137
+ const stored = input.kv?.get<string | undefined>(KV_SOUND_PACK, undefined)
138
+ return activePackID ?? stored ?? input.config.attention.sound_pack
139
+ }
140
+
141
+ function currentPack() {
142
+ return packs.get(configuredPackID()) ?? BUILTIN_PACK
143
+ }
144
+
145
+ function soundCandidates(name: TuiAttentionSoundName) {
146
+ return [input.config.attention.sounds[name], currentPack().sounds[name], BUILTIN_PACK.sounds[name]].filter(
147
+ (item, index, list): item is string => typeof item === "string" && list.indexOf(item) === index,
148
+ )
149
+ }
150
+
151
+ async function playSound(name: TuiAttentionSoundName, volume: number) {
152
+ try {
153
+ for (const file of soundCandidates(name)) {
154
+ const current = await audio.loadSoundFile(file).catch((error) => {
155
+ console.debug("failed to load attention sound", { file, error })
156
+ return null
157
+ })
158
+ if (disposed) return false
159
+ if (current == null) continue
160
+ if (audio.play(current, { volume }) != null) return true
161
+ }
162
+ return false
163
+ } catch (error) {
164
+ console.debug("failed to play attention sound", { error })
165
+ return false
166
+ }
167
+ }
168
+
169
+ return {
170
+ async notify(request) {
171
+ try {
172
+ if (!input.config.attention.enabled) return skipped("attention_disabled")
173
+ if (disposed || input.renderer.isDestroyed) return skipped("renderer_destroyed")
174
+
175
+ const message = normalizeText(request.message, "", MESSAGE_LIMIT)
176
+ if (!message) return skipped("empty_message")
177
+
178
+ const requestedNotification = typeof request.notification === "object" ? request.notification : undefined
179
+ const notificationSkip = focusSkip(requestedNotification?.when ?? "blurred", focus)
180
+ const notificationRequested = input.config.attention.notifications && request.notification !== false
181
+ const shouldNotify = notificationRequested && !notificationSkip
182
+ const notification = shouldNotify
183
+ ? (() => {
184
+ try {
185
+ return input.renderer.triggerNotification(
186
+ message,
187
+ normalizeText(request.title, DEFAULT_TITLE, TITLE_LIMIT),
188
+ )
189
+ } catch (error) {
190
+ console.debug("failed to trigger attention notification", { error })
191
+ return false
192
+ }
193
+ })()
194
+ : false
195
+ const volume = soundVolume(request, input.config)
196
+ const requestedSound = typeof request.sound === "object" ? request.sound : undefined
197
+ const soundSkip = volume === undefined ? undefined : focusSkip(requestedSound?.when ?? "always", focus)
198
+ const soundName =
199
+ requestedSound?.name && Schema.is(AttentionSoundName)(requestedSound.name) ? requestedSound.name : "default"
200
+ const sound = volume === undefined || soundSkip ? false : await playSound(soundName, volume)
201
+
202
+ if (!notification && !sound) {
203
+ if (notificationRequested && notificationSkip) return skipped(notificationSkip)
204
+ if (soundSkip) return skipped(soundSkip)
205
+ }
206
+
207
+ return {
208
+ ok: notification || sound,
209
+ notification,
210
+ sound,
211
+ }
212
+ } catch (error) {
213
+ console.debug("failed to handle attention notification", { error })
214
+ return {
215
+ ok: false,
216
+ notification: false,
217
+ sound: false,
218
+ }
219
+ }
220
+ },
221
+ soundboard: {
222
+ registerPack(pack) {
223
+ const next = normalizePack(pack)
224
+ if (!next) return () => {}
225
+ packs.set(next.id, next)
226
+ let disposed = false
227
+ return () => {
228
+ if (disposed) return
229
+ disposed = true
230
+ if (packs.get(next.id) === next) packs.delete(next.id)
231
+ }
232
+ },
233
+ activate(id, options) {
234
+ const pack = packs.get(id)
235
+ if (!pack) return false
236
+ activePackID = pack.id
237
+ if (options?.persist) input.kv?.set(KV_SOUND_PACK, pack.id)
238
+ return true
239
+ },
240
+ current() {
241
+ return currentPack().id
242
+ },
243
+ list(): TuiAttentionSoundPackInfo[] {
244
+ const current = currentPack().id
245
+ return Array.from(packs.values()).map((pack) => ({
246
+ id: pack.id,
247
+ name: pack.name,
248
+ active: pack.id === current,
249
+ builtin: pack.builtin,
250
+ }))
251
+ },
252
+ },
253
+ dispose() {
254
+ if (disposed) return
255
+ disposed = true
256
+ input.renderer.off("focus", onFocus)
257
+ input.renderer.off("blur", onBlur)
258
+ },
259
+ }
260
+ }
package/src/audio.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ declare module "*.mp3" {
2
+ const path: string
3
+ export default path
4
+ }
5
+
6
+ declare module "@neurocode-ai/ui/audio/*.mp3" {
7
+ const path: string
8
+ export default path
9
+ }
package/src/audio.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core"
2
+ import { readFile } from "node:fs/promises"
3
+
4
+ let audio: Audio | null | undefined
5
+ const sounds = new Map<string, Promise<AudioSound | null>>()
6
+
7
+ function getAudio() {
8
+ if (audio !== undefined) return audio
9
+ try {
10
+ const next = Audio.create({ autoStart: false })
11
+ next.on("error", (error: Error, context: AudioErrorContext) => {
12
+ console.debug("tui audio error", { error, context })
13
+ })
14
+ audio = next
15
+ return next
16
+ } catch (error) {
17
+ console.debug("failed to create tui audio", { error })
18
+ audio = null
19
+ return null
20
+ }
21
+ }
22
+
23
+ export function loadSoundFile(file: string) {
24
+ const current = getAudio()
25
+ if (!current) return Promise.resolve(null)
26
+ const cached = sounds.get(file)
27
+ if (cached) return cached
28
+ const task = readFile(file)
29
+ .then((bytes) => current.loadSound(bytes))
30
+ .catch((error) => {
31
+ console.debug("failed to load tui sound", { file, error })
32
+ return null
33
+ })
34
+ sounds.set(file, task)
35
+ return task
36
+ }
37
+
38
+ export function play(sound: AudioSound, options?: AudioPlayOptions) {
39
+ const current = getAudio()
40
+ if (!current) return null
41
+ if (!current.isStarted() && !current.start()) return null
42
+ return current.play(sound, options)
43
+ }
44
+
45
+ export function stopVoice(voice: AudioVoice) {
46
+ return audio?.stopVoice(voice) ?? false
47
+ }
48
+
49
+ export function dispose() {
50
+ audio?.dispose()
51
+ audio = undefined
52
+ sounds.clear()
53
+ }
@@ -0,0 +1,124 @@
1
+ import { execFile, spawn } from "node:child_process"
2
+ import { readFile, rm } from "node:fs/promises"
3
+ import { platform, release, tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import { promisify } from "node:util"
6
+
7
+ const exec = promisify(execFile)
8
+
9
+ function command(command: string, args: string[] = [], input?: string) {
10
+ return new Promise<Buffer>((resolve, reject) => {
11
+ const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] })
12
+ const output: Buffer[] = []
13
+ child.on("error", reject)
14
+ child.stdout?.on("data", (chunk: Buffer) => output.push(chunk))
15
+ child.on("close", (code) => {
16
+ if (code === 0) return resolve(Buffer.concat(output))
17
+ reject(new Error(`${command} exited with code ${code}`))
18
+ })
19
+ if (input !== undefined) child.stdin?.end(input)
20
+ })
21
+ }
22
+
23
+ function writeOsc52(text: string) {
24
+ if (!process.stdout.isTTY) return
25
+ const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`
26
+ process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence)
27
+ }
28
+
29
+ export async function read() {
30
+ if (platform() === "darwin") {
31
+ const file = path.join(tmpdir(), "neurocode-clipboard.png")
32
+ try {
33
+ await exec("osascript", [
34
+ "-e",
35
+ 'set imageData to the clipboard as "PNGf"',
36
+ "-e",
37
+ `set fileRef to open for access POSIX file "${file}" with write permission`,
38
+ "-e",
39
+ "set eof fileRef to 0",
40
+ "-e",
41
+ "write imageData to fileRef",
42
+ "-e",
43
+ "close access fileRef",
44
+ ])
45
+ return { data: (await readFile(file)).toString("base64"), mime: "image/png" }
46
+ } catch {
47
+ // Fall through to text clipboard.
48
+ } finally {
49
+ await rm(file, { force: true }).catch(() => {})
50
+ }
51
+ }
52
+
53
+ if (platform() === "win32" || release().includes("WSL")) {
54
+ const script =
55
+ "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
56
+ const image = await command("powershell.exe", ["-NonInteractive", "-NoProfile", "-command", script]).catch(() =>
57
+ Buffer.alloc(0),
58
+ )
59
+ if (image.length) return { data: image.toString().trim(), mime: "image/png" }
60
+ }
61
+
62
+ if (platform() === "linux") {
63
+ const wayland = await command("wl-paste", ["-t", "image/png"]).catch(() => Buffer.alloc(0))
64
+ if (wayland.length) return { data: wayland.toString("base64"), mime: "image/png" }
65
+ const x11 = await command("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]).catch(() =>
66
+ Buffer.alloc(0),
67
+ )
68
+ if (x11.length) return { data: x11.toString("base64"), mime: "image/png" }
69
+ }
70
+
71
+ const { default: clipboardy } = await import("clipboardy")
72
+ const text = await clipboardy.read().catch(() => undefined)
73
+ if (text) return { data: text, mime: "text/plain" }
74
+ }
75
+
76
+ export function copyCommand(
77
+ os: NodeJS.Platform,
78
+ wayland: boolean,
79
+ has: (name: string) => boolean,
80
+ ): string[] | undefined {
81
+ if (os === "darwin" && has("osascript")) return ["osascript"]
82
+ if (os === "linux" && wayland && has("wl-copy")) return ["wl-copy"]
83
+ if (os === "linux" && has("xclip")) return ["xclip", "-selection", "clipboard"]
84
+ if (os === "linux" && has("xsel")) return ["xsel", "--clipboard", "--input"]
85
+ if (os === "win32" && has("powershell.exe")) {
86
+ return [
87
+ "powershell.exe",
88
+ "-NonInteractive",
89
+ "-NoProfile",
90
+ "-Command",
91
+ "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
92
+ ]
93
+ }
94
+ }
95
+
96
+ let copyMethod: Promise<(text: string) => Promise<void>> | undefined
97
+
98
+ function getCopyMethod() {
99
+ return (copyMethod ??= (async () => {
100
+ const { which } = await import("@neurocode-ai/core/util/which")
101
+ const native = copyCommand(platform(), Boolean(process.env.WAYLAND_DISPLAY), (name) => Boolean(which(name)))
102
+ if (native?.[0] === "osascript") {
103
+ return async (text: string) => {
104
+ const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
105
+ await command("osascript", ["-e", `set the clipboard to "${escaped}"`]).catch(() => undefined)
106
+ }
107
+ }
108
+ if (native) {
109
+ return async (text: string) => {
110
+ await command(native[0], native.slice(1), text).catch(() => undefined)
111
+ }
112
+ }
113
+ return async (text: string) => {
114
+ const { default: clipboardy } = await import("clipboardy")
115
+ await clipboardy.write(text).catch(() => undefined)
116
+ }
117
+ })())
118
+ }
119
+
120
+ export async function write(text: string) {
121
+ writeOsc52(text)
122
+ const method = await getCopyMethod()
123
+ await method(text)
124
+ }