@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,267 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import Notifications from "../../../../src/feature-plugins/system/notifications"
3
+ import type { Event, PermissionRequest, QuestionRequest, Session } from "@neurocode-ai/sdk/v2"
4
+ import type { TuiAttentionNotifyInput } from "@neurocode-ai/plugin/tui"
5
+ import { createTuiPluginApi } from "../../../fixture/tui-plugin"
6
+
7
+ async function setup() {
8
+ const notifications: TuiAttentionNotifyInput[] = []
9
+ const handlers = new Map<Event["type"], ((event: Event) => void)[]>()
10
+ const session = (id: string, title: string, parentID?: string): Session => ({
11
+ id,
12
+ title,
13
+ slug: id,
14
+ projectID: "project",
15
+ directory: "/workspace",
16
+ ...(parentID && { parentID }),
17
+ version: "0.0.0-test",
18
+ time: { created: 0, updated: 0 },
19
+ })
20
+ const sessions: Record<string, Session> = {
21
+ session: session("session", "Demo session"),
22
+ subagent: session("subagent", "Subagent session", "session"),
23
+ abort: session("abort", "Abort session"),
24
+ timeout: session("timeout", "Timeout session"),
25
+ }
26
+
27
+ await Notifications.tui(
28
+ createTuiPluginApi({
29
+ attention: {
30
+ async notify(input) {
31
+ notifications.push(input)
32
+ return { ok: true, notification: true, sound: true }
33
+ },
34
+ },
35
+ event: {
36
+ on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => {
37
+ const list = handlers.get(type) ?? []
38
+ const wrapped = handler as (event: Event) => void
39
+ list.push(wrapped)
40
+ handlers.set(type, list)
41
+ return () => {
42
+ handlers.set(
43
+ type,
44
+ (handlers.get(type) ?? []).filter((item) => item !== wrapped),
45
+ )
46
+ }
47
+ },
48
+ },
49
+ state: {
50
+ session: {
51
+ get: (sessionID: string) => sessions[sessionID],
52
+ },
53
+ },
54
+ }),
55
+ undefined,
56
+ {} as never,
57
+ )
58
+
59
+ return {
60
+ notifications,
61
+ emit(event: Event) {
62
+ for (const handler of handlers.get(event.type) ?? []) handler(event)
63
+ },
64
+ }
65
+ }
66
+
67
+ function question(id: string, sessionID = "session"): QuestionRequest {
68
+ return {
69
+ id,
70
+ sessionID,
71
+ questions: [],
72
+ }
73
+ }
74
+
75
+ function permission(id: string, sessionID = "session"): PermissionRequest {
76
+ return {
77
+ id,
78
+ sessionID,
79
+ permission: "edit",
80
+ patterns: [],
81
+ metadata: {},
82
+ always: [],
83
+ }
84
+ }
85
+
86
+ const questionNotification: TuiAttentionNotifyInput = {
87
+ title: "Demo session",
88
+ message: "Question needs input",
89
+ notification: { when: "blurred" },
90
+ sound: { name: "question", when: "always" },
91
+ }
92
+
93
+ const permissionNotification: TuiAttentionNotifyInput = {
94
+ title: "Demo session",
95
+ message: "Permission needs input",
96
+ notification: { when: "blurred" },
97
+ sound: { name: "permission", when: "always" },
98
+ }
99
+
100
+ describe("internal notifications TUI plugin", () => {
101
+ test("notifies for question and permission requests with blurred notifications and always-on sounds", async () => {
102
+ const harness = await setup()
103
+
104
+ harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
105
+ harness.emit({ id: "event-2", type: "permission.asked", properties: permission("permission-1") })
106
+
107
+ expect(harness.notifications).toEqual([questionNotification, permissionNotification])
108
+ })
109
+
110
+ test("dedupes pending questions and permissions until they are resolved", async () => {
111
+ const harness = await setup()
112
+
113
+ harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
114
+ harness.emit({ id: "event-2", type: "question.asked", properties: question("question-1") })
115
+ harness.emit({
116
+ id: "event-3",
117
+ type: "question.replied",
118
+ properties: { sessionID: "session", requestID: "question-1", answers: [] },
119
+ })
120
+ harness.emit({ id: "event-4", type: "question.asked", properties: question("question-1") })
121
+
122
+ harness.emit({ id: "event-5", type: "permission.asked", properties: permission("permission-1") })
123
+ harness.emit({ id: "event-6", type: "permission.asked", properties: permission("permission-1") })
124
+ harness.emit({
125
+ id: "event-7",
126
+ type: "permission.replied",
127
+ properties: { sessionID: "session", requestID: "permission-1", reply: "once" },
128
+ })
129
+ harness.emit({ id: "event-8", type: "permission.asked", properties: permission("permission-1") })
130
+
131
+ expect(harness.notifications).toEqual([
132
+ questionNotification,
133
+ questionNotification,
134
+ permissionNotification,
135
+ permissionNotification,
136
+ ])
137
+ })
138
+
139
+ test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
140
+ const harness = await setup()
141
+
142
+ harness.emit({
143
+ id: "event-1",
144
+ type: "session.status",
145
+ properties: { sessionID: "session", status: { type: "idle" } },
146
+ })
147
+ harness.emit({
148
+ id: "event-2",
149
+ type: "session.status",
150
+ properties: { sessionID: "session", status: { type: "busy" } },
151
+ })
152
+ harness.emit({
153
+ id: "event-3",
154
+ type: "session.status",
155
+ properties: { sessionID: "session", status: { type: "idle" } },
156
+ })
157
+
158
+ expect(harness.notifications).toEqual([
159
+ {
160
+ title: "Demo session",
161
+ message: "Session done",
162
+ notification: { when: "blurred" },
163
+ sound: { name: "done", when: "always" },
164
+ },
165
+ ])
166
+ })
167
+
168
+ test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
169
+ const harness = await setup()
170
+
171
+ harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1", "subagent") })
172
+ harness.emit({
173
+ id: "event-2",
174
+ type: "session.status",
175
+ properties: { sessionID: "subagent", status: { type: "busy" } },
176
+ })
177
+ harness.emit({
178
+ id: "event-3",
179
+ type: "session.status",
180
+ properties: { sessionID: "subagent", status: { type: "idle" } },
181
+ })
182
+
183
+ expect(harness.notifications).toEqual([
184
+ {
185
+ title: "Subagent session",
186
+ message: "Question needs input",
187
+ notification: false,
188
+ sound: { name: "question", when: "always" },
189
+ },
190
+ {
191
+ title: "Subagent session",
192
+ message: "Session done",
193
+ notification: false,
194
+ sound: { name: "subagent_done", when: "always" },
195
+ },
196
+ ])
197
+ })
198
+
199
+ test("notifies session errors once and suppresses the following idle done notification", async () => {
200
+ const harness = await setup()
201
+
202
+ harness.emit({
203
+ id: "event-1",
204
+ type: "session.status",
205
+ properties: { sessionID: "session", status: { type: "busy" } },
206
+ })
207
+ harness.emit({
208
+ id: "event-2",
209
+ type: "session.error",
210
+ properties: { sessionID: "session", error: { name: "UnknownError", data: { message: "boom" } } },
211
+ })
212
+ harness.emit({
213
+ id: "event-3",
214
+ type: "session.status",
215
+ properties: { sessionID: "session", status: { type: "idle" } },
216
+ })
217
+
218
+ expect(harness.notifications).toEqual([
219
+ {
220
+ title: "Demo session",
221
+ message: "Session error",
222
+ notification: { when: "blurred" },
223
+ sound: { name: "error", when: "always" },
224
+ },
225
+ ])
226
+ })
227
+
228
+ test("special-cases aborts and model response timeouts", async () => {
229
+ const harness = await setup()
230
+
231
+ harness.emit({
232
+ id: "event-1",
233
+ type: "session.status",
234
+ properties: { sessionID: "abort", status: { type: "busy" } },
235
+ })
236
+ harness.emit({
237
+ id: "event-2",
238
+ type: "session.error",
239
+ properties: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
240
+ })
241
+ harness.emit({
242
+ id: "event-3",
243
+ type: "session.status",
244
+ properties: { sessionID: "timeout", status: { type: "busy" } },
245
+ })
246
+ harness.emit({
247
+ id: "event-4",
248
+ type: "session.error",
249
+ properties: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
250
+ })
251
+
252
+ expect(harness.notifications).toEqual([
253
+ {
254
+ title: "Abort session",
255
+ message: "Session aborted",
256
+ notification: { when: "blurred" },
257
+ sound: { name: "error", when: "always" },
258
+ },
259
+ {
260
+ title: "Timeout session",
261
+ message: "Model stopped responding",
262
+ notification: { when: "blurred" },
263
+ sound: { name: "error", when: "always" },
264
+ },
265
+ ])
266
+ })
267
+ })
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { normalizeCustomProviderID, providerOptions } from "../../../../src/component/dialog-provider"
3
+
4
+ describe("providerOptions", () => {
5
+ test("includes a synthetic Other option for custom providers", () => {
6
+ expect(providerOptions([{ id: "openai", name: "OpenAI" }]).at(-1)).toMatchObject({
7
+ title: "Other",
8
+ description: "Custom provider",
9
+ category: "Providers",
10
+ })
11
+ })
12
+
13
+ test("does not use Other as the generic provider category", () => {
14
+ expect(providerOptions([{ id: "mistral", name: "Mistral" }])[0]?.category).toBe("Providers")
15
+ })
16
+
17
+ test("keeps popular providers first and sorts the rest alphabetically", () => {
18
+ expect(
19
+ providerOptions([
20
+ { id: "openai", name: "OpenAI" },
21
+ { id: "custom-z", name: "Zebra Provider" },
22
+ { id: "anthropic", name: "Anthropic" },
23
+ { id: "mistral", name: "Mistral" },
24
+ { id: "aws", name: "AWS Bedrock" },
25
+ ]).map((option) => option.value),
26
+ ).toEqual(["openai", "anthropic", "aws", "mistral", "custom-z", "__NEUROCODE_custom_provider__"])
27
+ })
28
+
29
+ test("does not collide with a configured provider named other", () => {
30
+ const values = providerOptions([{ id: "other", name: "Other Provider" }]).map((option) => option.value)
31
+ expect(new Set(values).size).toBe(values.length)
32
+ })
33
+
34
+ test("normalizes and validates custom provider ids", () => {
35
+ expect(normalizeCustomProviderID(" custom-provider ")).toBe("custom-provider")
36
+ expect(normalizeCustomProviderID("custom_provider")).toBe("custom_provider")
37
+ expect(normalizeCustomProviderID("@ai-sdk/custom-provider")).toBe("custom-provider")
38
+ expect(normalizeCustomProviderID("-custom-provider")).toBeUndefined()
39
+ expect(normalizeCustomProviderID("Custom Provider")).toBeUndefined()
40
+ })
41
+ })
@@ -0,0 +1,70 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { testRender } from "@opentui/solid"
3
+ import { onMount } from "solid-js"
4
+ import { ArgsProvider } from "../../../../src/context/args"
5
+ import { KVProvider, useKV } from "../../../../src/context/kv"
6
+ import { ProjectProvider, useProject } from "../../../../src/context/project"
7
+ import { SDKProvider } from "../../../../src/context/sdk"
8
+ import { SyncProvider, useSync } from "../../../../src/context/sync"
9
+ import { PermissionProvider } from "../../../../src/context/permission"
10
+ import { ExitProvider } from "../../../../src/context/exit"
11
+ import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk"
12
+ import { TestTuiContexts } from "../../../fixture/tui-environment"
13
+ export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk"
14
+
15
+ export async function wait(fn: () => boolean, timeout = 2000) {
16
+ const start = Date.now()
17
+ while (!fn()) {
18
+ if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
19
+ await Bun.sleep(10)
20
+ }
21
+ }
22
+
23
+ type Ctx = { kv: ReturnType<typeof useKV>; project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }
24
+
25
+ export async function mount(override?: FetchHandler, state?: string) {
26
+ const calls = createFetch(override)
27
+ const events = createEventSource()
28
+ let sync!: ReturnType<typeof useSync>
29
+ let project!: ReturnType<typeof useProject>
30
+ let kv!: ReturnType<typeof useKV>
31
+ let done!: () => void
32
+ const ready = new Promise<void>((resolve) => {
33
+ done = resolve
34
+ })
35
+
36
+ function Probe() {
37
+ const ctx: Ctx = { kv: useKV(), project: useProject(), sync: useSync() }
38
+ onMount(() => {
39
+ sync = ctx.sync
40
+ project = ctx.project
41
+ kv = ctx.kv
42
+ done()
43
+ })
44
+ return <box />
45
+ }
46
+
47
+ const app = await testRender(() => (
48
+ <TestTuiContexts paths={state ? { state } : undefined}>
49
+ <ArgsProvider>
50
+ <KVProvider>
51
+ <SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}>
52
+ <PermissionProvider>
53
+ <ProjectProvider>
54
+ <ExitProvider exit={() => {}}>
55
+ <SyncProvider>
56
+ <Probe />
57
+ </SyncProvider>
58
+ </ExitProvider>
59
+ </ProjectProvider>
60
+ </PermissionProvider>
61
+ </SDKProvider>
62
+ </KVProvider>
63
+ </ArgsProvider>
64
+ </TestTuiContexts>
65
+ ))
66
+
67
+ await ready
68
+ await wait(() => sync.status === "complete")
69
+ return { app, emit: events.emit, kv, project, sync, session: calls.session }
70
+ }
@@ -0,0 +1,262 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { expect, test } from "bun:test"
3
+ import type { GlobalEvent } from "@neurocode-ai/sdk/v2"
4
+ import { tmpdir } from "../../../fixture/fixture"
5
+ import { json, mount, wait } from "./sync-fixture"
6
+
7
+ const sessionID = "ses_hydration_race"
8
+ const messageID = "msg_hydration_race"
9
+ const partID = "prt_hydration_race"
10
+ const session = {
11
+ id: sessionID,
12
+ title: "race",
13
+ time: { created: 0, updated: 0 },
14
+ version: "1.15.13",
15
+ directory: "/tmp/opencode/packages/opencode",
16
+ }
17
+ const assistant = {
18
+ id: messageID,
19
+ sessionID,
20
+ role: "assistant" as const,
21
+ agent: "build",
22
+ modelID: "model",
23
+ providerID: "test",
24
+ mode: "build",
25
+ parentID: "msg_user",
26
+ path: { cwd: session.directory, root: session.directory },
27
+ cost: 0,
28
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
29
+ time: { created: 1, completed: 2 },
30
+ }
31
+
32
+ function global(payload: GlobalEvent["payload"]): GlobalEvent {
33
+ return { directory: "/tmp/other", project: "proj_test", payload }
34
+ }
35
+
36
+ test("stale session hydration does not overwrite live message parts", async () => {
37
+ await using tmp = await tmpdir()
38
+ await Bun.write(`${tmp.path}/kv.json`, "{}")
39
+
40
+ let resolveMessages!: (response: Response) => void
41
+ const messages = new Promise<Response>((resolve) => {
42
+ resolveMessages = resolve
43
+ })
44
+ let requested = false
45
+ const { app, emit, sync } = await mount((url) => {
46
+ if (url.pathname === `/session/${sessionID}`) return json(session)
47
+ if (url.pathname === `/session/${sessionID}/message`) {
48
+ requested = true
49
+ return messages
50
+ }
51
+ if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
52
+ return undefined
53
+ }, tmp.path)
54
+
55
+ try {
56
+ const hydrate = sync.session.sync(sessionID)
57
+ await wait(() => requested)
58
+ emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
59
+ emit(
60
+ global({
61
+ id: "evt_part",
62
+ type: "message.part.updated",
63
+ properties: {
64
+ sessionID,
65
+ time: 2,
66
+ part: { id: partID, sessionID, messageID, type: "text", text: "visible live content" },
67
+ },
68
+ }),
69
+ )
70
+ await wait(() => sync.data.part[messageID]?.[0]?.type === "text")
71
+
72
+ resolveMessages(
73
+ json([
74
+ {
75
+ info: assistant,
76
+ parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }],
77
+ },
78
+ ]),
79
+ )
80
+ await hydrate
81
+
82
+ expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible live content" })
83
+ } finally {
84
+ app.renderer.destroy()
85
+ }
86
+ })
87
+
88
+ test("orphan live deltas do not suppress hydrated parts", async () => {
89
+ await using tmp = await tmpdir()
90
+ await Bun.write(`${tmp.path}/kv.json`, "{}")
91
+
92
+ let resolveMessages!: (response: Response) => void
93
+ const messages = new Promise<Response>((resolve) => {
94
+ resolveMessages = resolve
95
+ })
96
+ let requested = false
97
+ const { app, emit, sync } = await mount((url) => {
98
+ if (url.pathname === `/session/${sessionID}`) return json(session)
99
+ if (url.pathname === `/session/${sessionID}/message`) {
100
+ requested = true
101
+ return messages
102
+ }
103
+ if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
104
+ return undefined
105
+ }, tmp.path)
106
+
107
+ try {
108
+ const hydrate = sync.session.sync(sessionID)
109
+ await wait(() => requested)
110
+ emit(
111
+ global({
112
+ id: "evt_delta",
113
+ type: "message.part.delta",
114
+ properties: { sessionID, messageID, partID, field: "text", delta: "ignored until part exists" },
115
+ }),
116
+ )
117
+ resolveMessages(
118
+ json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "hydrated" }] }]),
119
+ )
120
+ await hydrate
121
+
122
+ expect(sync.data.part[messageID][0]).toMatchObject({ text: "hydrated" })
123
+ } finally {
124
+ app.renderer.destroy()
125
+ }
126
+ })
127
+
128
+ test("hydration does not clear text streamed before it starts", async () => {
129
+ await using tmp = await tmpdir()
130
+ await Bun.write(`${tmp.path}/kv.json`, "{}")
131
+
132
+ let resolveMessages!: (response: Response) => void
133
+ const messages = new Promise<Response>((resolve) => {
134
+ resolveMessages = resolve
135
+ })
136
+ let requested = false
137
+ const { app, emit, sync } = await mount((url) => {
138
+ if (url.pathname === `/session/${sessionID}`) return json(session)
139
+ if (url.pathname === `/session/${sessionID}/message`) {
140
+ requested = true
141
+ return messages
142
+ }
143
+ if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
144
+ return undefined
145
+ }, tmp.path)
146
+
147
+ try {
148
+ emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
149
+ emit(
150
+ global({
151
+ id: "evt_part",
152
+ type: "message.part.updated",
153
+ properties: {
154
+ sessionID,
155
+ time: 1,
156
+ part: { id: partID, sessionID, messageID, type: "text", text: "" },
157
+ },
158
+ }),
159
+ )
160
+ emit(
161
+ global({
162
+ id: "evt_delta",
163
+ type: "message.part.delta",
164
+ properties: { sessionID, messageID, partID, field: "text", delta: "visible streamed content" },
165
+ }),
166
+ )
167
+ await wait(() => sync.data.part[messageID]?.[0]?.type === "text" && sync.data.part[messageID][0].text !== "")
168
+ const hydrate = sync.session.sync(sessionID)
169
+ await wait(() => requested)
170
+ resolveMessages(json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }] }]))
171
+ await hydrate
172
+
173
+ expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible streamed content" })
174
+ } finally {
175
+ app.renderer.destroy()
176
+ }
177
+ })
178
+
179
+ test("live messages merged during hydration retain the 100 message window", async () => {
180
+ await using tmp = await tmpdir()
181
+ await Bun.write(`${tmp.path}/kv.json`, "{}")
182
+
183
+ let resolveMessages!: (response: Response) => void
184
+ const messages = new Promise<Response>((resolve) => {
185
+ resolveMessages = resolve
186
+ })
187
+ let requested = false
188
+ const { app, emit, sync } = await mount((url) => {
189
+ if (url.pathname === `/session/${sessionID}`) return json(session)
190
+ if (url.pathname === `/session/${sessionID}/message`) {
191
+ requested = true
192
+ return messages
193
+ }
194
+ if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
195
+ return undefined
196
+ }, tmp.path)
197
+
198
+ try {
199
+ const hydrate = sync.session.sync(sessionID)
200
+ await wait(() => requested)
201
+ const live = { ...assistant, id: "msg_z_live" }
202
+ emit(global({ id: "evt_live", type: "message.updated", properties: { sessionID, info: live } }))
203
+ await wait(() => sync.data.message[sessionID]?.some((message) => message.id === live.id) ?? false)
204
+ resolveMessages(
205
+ json(
206
+ Array.from({ length: 100 }, (_, index) => {
207
+ const id = `msg_${String(index).padStart(3, "0")}`
208
+ return {
209
+ info: { ...assistant, id },
210
+ parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text: id }],
211
+ }
212
+ }),
213
+ ),
214
+ )
215
+ await hydrate
216
+
217
+ expect(sync.data.message[sessionID]).toHaveLength(100)
218
+ expect(sync.data.message[sessionID].at(-1)?.id).toBe(live.id)
219
+ expect(sync.data.message[sessionID].some((message) => message.id === "msg_000")).toBe(false)
220
+ expect(sync.data.part.msg_000).toBeUndefined()
221
+ } finally {
222
+ app.renderer.destroy()
223
+ }
224
+ })
225
+
226
+ test("a message removed during hydration does not regain stale parts", async () => {
227
+ await using tmp = await tmpdir()
228
+ await Bun.write(`${tmp.path}/kv.json`, "{}")
229
+
230
+ let resolveMessages!: (response: Response) => void
231
+ const messages = new Promise<Response>((resolve) => {
232
+ resolveMessages = resolve
233
+ })
234
+ let requested = false
235
+ const { app, emit, sync } = await mount((url) => {
236
+ if (url.pathname === `/session/${sessionID}`) return json(session)
237
+ if (url.pathname === `/session/${sessionID}/message`) {
238
+ requested = true
239
+ return messages
240
+ }
241
+ if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
242
+ return undefined
243
+ }, tmp.path)
244
+
245
+ try {
246
+ emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
247
+ await wait(() => sync.data.message[sessionID]?.length === 1)
248
+ const hydrate = sync.session.sync(sessionID)
249
+ await wait(() => requested)
250
+ emit(global({ id: "evt_removed", type: "message.removed", properties: { sessionID, messageID } }))
251
+ await wait(() => sync.data.message[sessionID]?.length === 0)
252
+ resolveMessages(
253
+ json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "stale" }] }]),
254
+ )
255
+ await hydrate
256
+
257
+ expect(sync.data.message[sessionID]).toEqual([])
258
+ expect(sync.data.part[messageID]).toBeUndefined()
259
+ } finally {
260
+ app.renderer.destroy()
261
+ }
262
+ })
@@ -0,0 +1,43 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ /**
3
+ * Reproducer for #26560 — TUI crashes with
4
+ * `TypeError: undefined is not an object (evaluating 'f.data.map')`
5
+ * when entering a session whose messages endpoint returns a non-2xx.
6
+ * The failure path is `sync.tsx#sync.session.sync` reading
7
+ * `messages.data!` while the SDK leaves `data` undefined on error.
8
+ */
9
+ import { describe, expect, test } from "bun:test"
10
+ import { tmpdir } from "../../../fixture/fixture"
11
+ import { directory, json, mount } from "./sync-fixture"
12
+
13
+ const sessionID = "ses_undef"
14
+
15
+ describe("tui sync (#26560)", () => {
16
+ test("entering a session whose messages endpoint errors does not crash sync", async () => {
17
+ await using tmp = await tmpdir()
18
+ await Bun.write(`${tmp.path}/kv.json`, "{}")
19
+
20
+ const sessionPayload = {
21
+ id: sessionID,
22
+ title: "broken",
23
+ time: { created: 0, updated: 0 },
24
+ version: "1.14.42",
25
+ directory,
26
+ project_id: "proj_test",
27
+ }
28
+ const { app, sync } = await mount((url) => {
29
+ if (url.pathname === `/session/${sessionID}`) return json(sessionPayload)
30
+ if (url.pathname === `/session/${sessionID}/messages`) return json({}, { status: 500 })
31
+ if (url.pathname === `/session/${sessionID}/todo`) return json([])
32
+ if (url.pathname === `/session/${sessionID}/diff`) return json([])
33
+ if (url.pathname === "/session") return json([sessionPayload])
34
+ return undefined
35
+ }, tmp.path)
36
+
37
+ try {
38
+ await expect(sync.session.sync(sessionID)).resolves.toBeUndefined()
39
+ } finally {
40
+ app.renderer.destroy()
41
+ }
42
+ })
43
+ })