@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,65 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { describe, expect, test } from "bun:test"
3
+ import { tmpdir } from "../../../fixture/fixture"
4
+ import { mount, wait } from "./sync-fixture"
5
+ import type { GlobalEvent } from "@neurocode-ai/sdk/v2"
6
+
7
+ function branchEvent(branch: string, workspace?: string): GlobalEvent {
8
+ return {
9
+ directory: "/tmp/other",
10
+ project: "proj_test",
11
+ workspace,
12
+ payload: {
13
+ id: `evt_vcs_${branch}`,
14
+ type: "vcs.branch.updated",
15
+ properties: { branch },
16
+ },
17
+ }
18
+ }
19
+
20
+ describe("tui sync", () => {
21
+ test("refresh scopes sessions by default and lists project sessions when disabled", async () => {
22
+ await using tmp = await tmpdir()
23
+ await Bun.write(`${tmp.path}/kv.json`, "{}")
24
+ const { app, kv, sync, session } = await mount(undefined, tmp.path)
25
+
26
+ try {
27
+ expect(kv.get("session_directory_filter_enabled", true)).toBe(true)
28
+ expect(session.at(-1)?.searchParams.get("roots")).toBeNull()
29
+ expect(session.at(-1)?.searchParams.get("scope")).toBeNull()
30
+ expect(session.at(-1)?.searchParams.get("path")).toBe("packages/tui")
31
+
32
+ kv.set("session_directory_filter_enabled", false)
33
+ await sync.session.refresh()
34
+
35
+ expect(session.at(-1)?.searchParams.get("scope")).toBe("project")
36
+ expect(session.at(-1)?.searchParams.get("path")).toBeNull()
37
+ expect(session.at(-1)?.searchParams.get("roots")).toBeNull()
38
+ } finally {
39
+ app.renderer.destroy()
40
+ }
41
+ })
42
+
43
+ test("vcs branch updates only apply for the active workspace", async () => {
44
+ await using tmp = await tmpdir()
45
+ await Bun.write(`${tmp.path}/kv.json`, "{}")
46
+ const { app, emit, project, sync } = await mount(undefined, tmp.path)
47
+
48
+ try {
49
+ expect(sync.data.vcs?.branch).toBe("main")
50
+
51
+ project.workspace.set("ws_a")
52
+ emit(branchEvent("other", "ws_b"))
53
+ await Bun.sleep(30)
54
+
55
+ expect(sync.data.vcs?.branch).toBe("main")
56
+
57
+ emit(branchEvent("feature", "ws_a"))
58
+ await wait(() => sync.data.vcs?.branch === "feature")
59
+
60
+ expect(sync.data.vcs?.branch).toBe("feature")
61
+ } finally {
62
+ app.renderer.destroy()
63
+ }
64
+ })
65
+ })
@@ -0,0 +1,92 @@
1
+ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots
2
+
3
+ exports[`TUI inline tool wrapping snapshots consecutive grep, glob, and read rows at a narrow width 1`] = `
4
+ " ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
5
+ *dir|xdg|APPDATA" in packages/opencode/src (151 matches)
6
+
7
+ ✱ Glob "**/*db*" in packages/opencode (6 matches)
8
+ → Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
9
+ → Read packages/opencode/src/index.ts [offset=1, limit=100]
10
+ ✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
11
+ Path\\.data|data =" in packages/opencode/src (115 matches)"
12
+ `;
13
+
14
+ exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool text 1`] = `
15
+ " ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
16
+ *dir|xdg|APPDATA" in packages/opencode/src (151 matches)
17
+
18
+ ✱ Glob "**/*db*" in packages/opencode (6 matches)
19
+ → Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
20
+ → Read packages/opencode/src/index.ts [offset=1, limit=100]
21
+ No LSP server available for this file type.
22
+
23
+ ✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
24
+ Path\\.data|data =" in packages/opencode/src (115 matches)"
25
+ `;
26
+
27
+ exports[`TUI inline tool wrapping keeps separation after a shell output block 1`] = `
28
+ "
29
+
30
+ $ ls
31
+
32
+ file.ts
33
+
34
+ ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
35
+ *dir|xdg|APPDATA" in packages/opencode/src (151 matches)
36
+
37
+ ✱ Glob "**/*db*" in packages/opencode (6 matches)
38
+ → Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
39
+ → Read packages/opencode/src/index.ts [offset=1, limit=100]
40
+ ✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
41
+ Path\\.data|data =" in packages/opencode/src (115 matches)"
42
+ `;
43
+
44
+ exports[`TUI inline tool wrapping keeps separation after a padded user message 1`] = `
45
+ "
46
+ Check whether the next tool remains separated.
47
+
48
+
49
+ ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
50
+ *dir|xdg|APPDATA" in packages/opencode/src (151 matches)
51
+
52
+ ✱ Glob "**/*db*" in packages/opencode (6 matches)
53
+ → Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
54
+ → Read packages/opencode/src/index.ts [offset=1, limit=100]
55
+ ✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
56
+ Path\\.data|data =" in packages/opencode/src (115 matches)"
57
+ `;
58
+
59
+ exports[`TUI inline tool wrapping separates after a multi-line task row 1`] = `
60
+ " ✱ Grep "Task" (2 matches)
61
+
62
+ ⠙ Explore Task — Inspect active task spacing
63
+
64
+ ✓ General Task — Confirm completed task spacing
65
+ ↳ 1 toolcall · 501ms
66
+
67
+ → Read src/cli/cmd/tui/routes/session/index.tsx"
68
+ `;
69
+
70
+ exports[`TUI inline tool wrapping separates a task row from a preceding inline detail 1`] = `
71
+ " → Read src/cli/cmd/tui/routes/session/index.tsx
72
+ ↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx
73
+
74
+ ✓ Explore Task — Inspect active task spacing
75
+ ↳ 1 toolcall · 501ms"
76
+ `;
77
+
78
+ exports[`TUI inline tool wrapping separates an inline row from the previous assistant summary 1`] = `
79
+ " ▣ Build · Little Frank · 53.1s
80
+
81
+ ✓ Build Task — Review changes
82
+ ↳ 48 toolcalls · 1m 40s"
83
+ `;
84
+
85
+ exports[`TUI inline tool wrapping separates an inline row from the previous assistant error 1`] = `
86
+ "│
87
+ │ Managed inference requires an active Member plan
88
+
89
+
90
+ ✓ Build Task — Review changes
91
+ ↳ 48 toolcalls · 1m 40s"
92
+ `;
@@ -0,0 +1,486 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { expect, test } from "bun:test"
3
+ import { testRender } from "@opentui/solid"
4
+ import type { Event, GlobalEvent } from "@neurocode-ai/sdk/v2"
5
+ import { onMount } from "solid-js"
6
+ import { ProjectProvider } from "../../../src/context/project"
7
+ import { SDKProvider } from "../../../src/context/sdk"
8
+ import { DataProvider, useData } from "../../../src/context/data"
9
+ import { createEventSource, createFetch, directory, json } from "../../fixture/tui-sdk"
10
+ import { TestTuiContexts } from "../../fixture/tui-environment"
11
+
12
+ async function wait(fn: () => boolean, timeout = 2000) {
13
+ const start = Date.now()
14
+ while (!fn()) {
15
+ if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
16
+ await Bun.sleep(10)
17
+ }
18
+ }
19
+
20
+ function global(payload: Event): GlobalEvent {
21
+ return { directory, project: "proj_test", payload }
22
+ }
23
+
24
+ function emitEvent(events: ReturnType<typeof createEventSource>, payload: Event) {
25
+ events.emit(global(payload))
26
+ }
27
+
28
+ test("refreshes resources into reactive getters", async () => {
29
+ const events = createEventSource()
30
+ const location = {
31
+ directory,
32
+ project: { id: "proj_test", directory },
33
+ }
34
+ const calls = createFetch((url) => {
35
+ if (url.pathname === "/api/session/ses_test")
36
+ return json({
37
+ data: {
38
+ id: "ses_test",
39
+ projectID: "proj_test",
40
+ cost: 0,
41
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
42
+ time: { created: 0, updated: 0 },
43
+ title: "Test session",
44
+ location: { directory },
45
+ },
46
+ })
47
+ if (url.pathname === "/api/agent")
48
+ return json({
49
+ location,
50
+ data: [{ id: "build", request: { headers: {}, body: {} }, mode: "primary", hidden: false, permissions: [] }],
51
+ })
52
+ return undefined
53
+ }, events)
54
+ let data!: ReturnType<typeof useData>
55
+ let ready!: () => void
56
+ const mounted = new Promise<void>((resolve) => {
57
+ ready = resolve
58
+ })
59
+
60
+ function Probe() {
61
+ data = useData()
62
+ onMount(ready)
63
+ return <box />
64
+ }
65
+
66
+ const app = await testRender(() => (
67
+ <TestTuiContexts>
68
+ <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
69
+ <ProjectProvider>
70
+ <DataProvider>
71
+ <Probe />
72
+ </DataProvider>
73
+ </ProjectProvider>
74
+ </SDKProvider>
75
+ </TestTuiContexts>
76
+ ))
77
+
78
+ try {
79
+ await mounted
80
+ expect(data.location.default()).toEqual({ directory })
81
+ expect(data.session.get("ses_test")).toBeUndefined()
82
+ expect(data.location.agent.list(location)).toBeUndefined()
83
+
84
+ await data.session.refresh("ses_test")
85
+ await data.location.agent.refresh()
86
+
87
+ expect(data.session.get("ses_test")?.title).toBe("Test session")
88
+ expect(data.location.default()).toEqual({ directory, workspaceID: undefined })
89
+ expect(data.location.agent.list(location)?.map((agent) => agent.id)).toEqual(["build"])
90
+ } finally {
91
+ app.renderer.destroy()
92
+ }
93
+ })
94
+
95
+ test("refreshes integrations after integration updates", async () => {
96
+ const events = createEventSource()
97
+ const requests = { integration: 0, model: 0, provider: 0 }
98
+ const calls = createFetch((url) => {
99
+ if (url.pathname === "/api/model") {
100
+ requests.model++
101
+ return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
102
+ }
103
+ if (url.pathname === "/api/provider") {
104
+ requests.provider++
105
+ return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
106
+ }
107
+ if (url.pathname !== "/api/integration") return
108
+ requests.integration++
109
+ return json({
110
+ location: { directory, project: { id: "proj_test", directory } },
111
+ data:
112
+ requests.integration === 1
113
+ ? []
114
+ : [
115
+ {
116
+ id: "openai",
117
+ name: "OpenAI",
118
+ methods: [{ type: "key" }],
119
+ },
120
+ ],
121
+ })
122
+ }, events)
123
+ let data!: ReturnType<typeof useData>
124
+ let ready!: () => void
125
+ const mounted = new Promise<void>((resolve) => {
126
+ ready = resolve
127
+ })
128
+
129
+ function Probe() {
130
+ data = useData()
131
+ onMount(ready)
132
+ return <box />
133
+ }
134
+
135
+ const app = await testRender(() => (
136
+ <TestTuiContexts>
137
+ <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
138
+ <ProjectProvider>
139
+ <DataProvider>
140
+ <Probe />
141
+ </DataProvider>
142
+ </ProjectProvider>
143
+ </SDKProvider>
144
+ </TestTuiContexts>
145
+ ))
146
+
147
+ try {
148
+ await mounted
149
+ await wait(() => data.location.integration.list() !== undefined)
150
+ expect(data.location.integration.list()).toEqual([])
151
+ const before = { ...requests }
152
+
153
+ emitEvent(events, { id: "evt_integration", type: "integration.updated", properties: {} })
154
+ await wait(() => data.location.integration.list()?.length === 1)
155
+ await wait(() => requests.model > before.model && requests.provider > before.provider)
156
+ expect(data.location.integration.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" })
157
+ } finally {
158
+ app.renderer.destroy()
159
+ }
160
+ })
161
+
162
+ test("refreshes effective catalog data after catalog updates", async () => {
163
+ const events = createEventSource()
164
+ const requests = { model: 0, provider: 0 }
165
+ const calls = createFetch((url) => {
166
+ if (url.pathname === "/api/model") {
167
+ requests.model++
168
+ return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
169
+ }
170
+ if (url.pathname === "/api/provider") {
171
+ requests.provider++
172
+ return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
173
+ }
174
+ }, events)
175
+
176
+ const app = await testRender(() => (
177
+ <TestTuiContexts>
178
+ <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
179
+ <ProjectProvider>
180
+ <DataProvider>
181
+ <box />
182
+ </DataProvider>
183
+ </ProjectProvider>
184
+ </SDKProvider>
185
+ </TestTuiContexts>
186
+ ))
187
+
188
+ try {
189
+ await wait(() => requests.model > 0 && requests.provider > 0)
190
+ const before = { ...requests }
191
+ emitEvent(events, { id: "evt_catalog", type: "catalog.updated", properties: {} })
192
+ await wait(() => requests.model > before.model && requests.provider > before.provider)
193
+ } finally {
194
+ app.renderer.destroy()
195
+ }
196
+ })
197
+
198
+ test("refreshes references after updates", async () => {
199
+ const events = createEventSource()
200
+ let requests = 0
201
+ const calls = createFetch((url) => {
202
+ if (url.pathname !== "/api/reference") return
203
+ requests++
204
+ return json({
205
+ location: { directory, project: { id: "proj_test", directory } },
206
+ data: requests === 1 ? [] : [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }],
207
+ })
208
+ }, events)
209
+ let data!: ReturnType<typeof useData>
210
+ let ready!: () => void
211
+ const mounted = new Promise<void>((resolve) => {
212
+ ready = resolve
213
+ })
214
+
215
+ function Probe() {
216
+ data = useData()
217
+ onMount(ready)
218
+ return <box />
219
+ }
220
+
221
+ const app = await testRender(() => (
222
+ <TestTuiContexts>
223
+ <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
224
+ <ProjectProvider>
225
+ <DataProvider>
226
+ <Probe />
227
+ </DataProvider>
228
+ </ProjectProvider>
229
+ </SDKProvider>
230
+ </TestTuiContexts>
231
+ ))
232
+
233
+ try {
234
+ await mounted
235
+ await wait(() => requests === 1)
236
+ emitEvent(events, { id: "evt_reference_1", type: "reference.updated", properties: {} })
237
+ await wait(() => data.location.reference.list()?.length === 1)
238
+ expect(data.location.reference.list()?.[0]?.name).toBe("docs")
239
+ } finally {
240
+ app.renderer.destroy()
241
+ }
242
+ })
243
+
244
+ test("settles pending tools when a live failure arrives", async () => {
245
+ const events = createEventSource()
246
+ const calls = createFetch(undefined, events)
247
+ let sync!: ReturnType<typeof useData>
248
+ let ready!: () => void
249
+ const mounted = new Promise<void>((resolve) => {
250
+ ready = resolve
251
+ })
252
+
253
+ function Probe() {
254
+ sync = useData()
255
+ onMount(ready)
256
+ return <box />
257
+ }
258
+
259
+ const app = await testRender(() => (
260
+ <TestTuiContexts>
261
+ <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
262
+ <ProjectProvider>
263
+ <DataProvider>
264
+ <Probe />
265
+ </DataProvider>
266
+ </ProjectProvider>
267
+ </SDKProvider>
268
+ </TestTuiContexts>
269
+ ))
270
+
271
+ try {
272
+ await mounted
273
+ emitEvent(events, {
274
+ id: "evt_agent_1",
275
+ type: "session.next.agent.switched",
276
+ properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
277
+ })
278
+ emitEvent(events, {
279
+ id: "evt_model_1",
280
+ type: "session.next.model.switched",
281
+ properties: {
282
+ sessionID: "session-1",
283
+ messageID: "msg_model_1",
284
+ timestamp: 0,
285
+ model: { id: "model-1", providerID: "provider-1" },
286
+ },
287
+ })
288
+ emitEvent(events, {
289
+ id: "evt_step_started_1",
290
+ type: "session.next.step.started",
291
+ properties: {
292
+ sessionID: "session-1",
293
+ assistantMessageID: "msg_explicit_assistant_9",
294
+ timestamp: 1,
295
+ agent: "build",
296
+ model: { id: "model-1", providerID: "provider-1" },
297
+ },
298
+ })
299
+ emitEvent(events, {
300
+ id: "evt_input_1",
301
+ type: "session.next.tool.input.started",
302
+ properties: {
303
+ sessionID: "session-1",
304
+ assistantMessageID: "msg_explicit_assistant_9",
305
+ timestamp: 2,
306
+ callID: "call-1",
307
+ name: "bash",
308
+ },
309
+ })
310
+ emitEvent(events, {
311
+ id: "evt_called_1",
312
+ type: "session.next.tool.called",
313
+ properties: {
314
+ sessionID: "session-1",
315
+ timestamp: 2,
316
+ assistantMessageID: "msg_explicit_assistant_9",
317
+ callID: "call-1",
318
+ tool: "bash",
319
+ input: {},
320
+ provider: { executed: false, metadata: { fake: { call: true } } },
321
+ },
322
+ })
323
+ emitEvent(events, {
324
+ id: "evt_failed_1",
325
+ type: "session.next.tool.failed",
326
+ properties: {
327
+ sessionID: "session-1",
328
+ timestamp: 3,
329
+ assistantMessageID: "msg_explicit_assistant_9",
330
+ callID: "call-1",
331
+ error: { type: "unknown", message: "aborted" },
332
+ provider: { executed: false, metadata: { fake: { result: true } } },
333
+ },
334
+ })
335
+
336
+ await wait(() => {
337
+ const assistant = sync.session.message.list("session-1")?.[0]
338
+ return (
339
+ assistant?.type === "assistant" &&
340
+ assistant.content[0]?.type === "tool" &&
341
+ assistant.content[0].state.status === "error"
342
+ )
343
+ })
344
+
345
+ const assistant = sync.session.message.list("session-1")?.[0]
346
+ expect(assistant?.type).toBe("assistant")
347
+ if (assistant?.type !== "assistant") return
348
+ expect(assistant.id).toBe("msg_explicit_assistant_9")
349
+ const tool = assistant.content[0]
350
+ expect(tool?.type).toBe("tool")
351
+ if (tool?.type !== "tool") return
352
+ expect(tool.state.status).toBe("error")
353
+ if (tool.state.status !== "error") return
354
+ expect(tool.state.error).toEqual({ type: "unknown", message: "aborted" })
355
+ expect(tool.state.input).toEqual({})
356
+ expect(tool.state.structured).toEqual({})
357
+ expect(tool.state.content).toEqual([])
358
+ expect(tool.provider).toEqual({
359
+ executed: false,
360
+ metadata: { fake: { call: true } },
361
+ resultMetadata: { fake: { result: true } },
362
+ })
363
+ expect((sync.session.message.list("session-1") ?? []).map((message) => message.type)).toEqual([
364
+ "assistant",
365
+ "model-switched",
366
+ "agent-switched",
367
+ ])
368
+ } finally {
369
+ app.renderer.destroy()
370
+ }
371
+ })
372
+
373
+ test("renders admitted prompts only after they become model-visible", async () => {
374
+ const events = createEventSource()
375
+ const calls = createFetch(undefined, events)
376
+ let sync!: ReturnType<typeof useData>
377
+ let ready!: () => void
378
+ const mounted = new Promise<void>((resolve) => {
379
+ ready = resolve
380
+ })
381
+
382
+ function Probe() {
383
+ sync = useData()
384
+ onMount(ready)
385
+ return <box />
386
+ }
387
+
388
+ const app = await testRender(() => (
389
+ <TestTuiContexts>
390
+ <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
391
+ <ProjectProvider>
392
+ <DataProvider>
393
+ <Probe />
394
+ </DataProvider>
395
+ </ProjectProvider>
396
+ </SDKProvider>
397
+ </TestTuiContexts>
398
+ ))
399
+
400
+ try {
401
+ await mounted
402
+ emitEvent(events, {
403
+ id: "evt_admitted_1",
404
+ type: "session.next.prompt.admitted",
405
+ properties: {
406
+ sessionID: "session-1",
407
+ messageID: "msg_user_1",
408
+ timestamp: 0,
409
+ prompt: { text: "hello" },
410
+ delivery: "steer",
411
+ },
412
+ })
413
+ expect(sync.session.message.list("session-1") ?? []).toEqual([])
414
+
415
+ emitEvent(events, {
416
+ id: "evt_prompted_1",
417
+ type: "session.next.prompted",
418
+ properties: {
419
+ sessionID: "session-1",
420
+ messageID: "msg_user_1",
421
+ timestamp: 0,
422
+ prompt: { text: "hello" },
423
+ delivery: "steer",
424
+ },
425
+ })
426
+
427
+ await wait(() => sync.session.message.list("session-1")?.length === 1)
428
+ const message = sync.session.message.list("session-1")?.[0]
429
+ expect(message?.type).toBe("user")
430
+ if (message?.type !== "user") return
431
+ expect(message).toMatchObject({ id: "msg_user_1", text: "hello" })
432
+ } finally {
433
+ app.renderer.destroy()
434
+ }
435
+ })
436
+
437
+ test("projects live context updates with their message ID", async () => {
438
+ const events = createEventSource()
439
+ const calls = createFetch(undefined, events)
440
+ let sync!: ReturnType<typeof useData>
441
+ let ready!: () => void
442
+ const mounted = new Promise<void>((resolve) => {
443
+ ready = resolve
444
+ })
445
+
446
+ function Probe() {
447
+ sync = useData()
448
+ onMount(ready)
449
+ return <box />
450
+ }
451
+
452
+ const app = await testRender(() => (
453
+ <TestTuiContexts>
454
+ <SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
455
+ <ProjectProvider>
456
+ <DataProvider>
457
+ <Probe />
458
+ </DataProvider>
459
+ </ProjectProvider>
460
+ </SDKProvider>
461
+ </TestTuiContexts>
462
+ ))
463
+
464
+ try {
465
+ await mounted
466
+ emitEvent(events, {
467
+ id: "evt_context_1",
468
+ type: "session.next.context.updated",
469
+ properties: {
470
+ sessionID: "session-1",
471
+ messageID: "msg_context_1",
472
+ timestamp: 1,
473
+ text: "Updated context",
474
+ },
475
+ })
476
+
477
+ await wait(() => sync.session.message.list("session-1")?.length === 1)
478
+ expect(sync.session.message.list("session-1")?.[0]).toMatchObject({
479
+ id: "msg_context_1",
480
+ type: "system",
481
+ text: "Updated context",
482
+ })
483
+ } finally {
484
+ app.renderer.destroy()
485
+ }
486
+ })