@cueloop/client 0.1.0-alpha.11 → 0.1.0-alpha.111

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 (360) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +42 -34
  8. package/src/annotations/annotation-palette.ts +63 -0
  9. package/src/annotations/components/AnnotationCards.stories.tsx +86 -0
  10. package/src/annotations/components/AnnotationCards.test.tsx +42 -0
  11. package/src/annotations/components/AnnotationCards.tsx +383 -0
  12. package/src/annotations/components/DiscussionMarkerRail.stories.tsx +54 -0
  13. package/src/annotations/components/DiscussionMarkerRail.tsx +189 -0
  14. package/src/annotations/discussions.ts +78 -0
  15. package/src/annotations/thread-selection.test.ts +210 -0
  16. package/src/annotations/thread-selection.ts +248 -0
  17. package/src/annotations/use-annotation-surface.tsx +1378 -0
  18. package/src/app/App.appearance.test.tsx +53 -0
  19. package/src/{App.autoclose.test.tsx → app/App.autoclose.test.tsx} +77 -51
  20. package/src/app/App.deleted-scroll.test.tsx +190 -0
  21. package/src/app/App.diff.test.tsx +313 -0
  22. package/src/app/App.export.test.tsx +105 -0
  23. package/src/app/App.focus.test.ts +89 -0
  24. package/src/app/App.harness-settings.test.tsx +77 -0
  25. package/src/app/App.identity.test.tsx +84 -0
  26. package/src/app/App.plan-render.test.tsx +211 -0
  27. package/src/app/App.plan-review.test.tsx +429 -0
  28. package/src/app/App.prototype-markdown.test.tsx +129 -0
  29. package/src/app/App.prototype.test.tsx +176 -0
  30. package/src/app/App.readonly.test.tsx +274 -0
  31. package/src/app/App.regression.test.tsx +99 -0
  32. package/src/app/App.rendering.test.tsx +134 -0
  33. package/src/app/App.share-dialog.test.tsx +177 -0
  34. package/src/app/App.submit-confirm.test.tsx +267 -0
  35. package/src/app/App.test.tsx +497 -0
  36. package/src/app/App.thread-agent.test.ts +20 -0
  37. package/src/app/App.toast.test.tsx +124 -0
  38. package/src/app/App.tsx +1758 -0
  39. package/src/app/App.visual.test.tsx +60 -0
  40. package/src/app/App.walk.test.tsx +300 -0
  41. package/src/app/App.workbench.test.tsx +1153 -0
  42. package/src/app/app-key-state.ts +61 -0
  43. package/src/app/app-screens.tsx +607 -0
  44. package/src/app/app-view-model.ts +178 -0
  45. package/src/app/components/AppHeader.stories.tsx +50 -0
  46. package/src/app/components/AppHeader.tsx +81 -0
  47. package/src/app/components/AppShell.brand.test.tsx +58 -0
  48. package/src/app/components/AppShell.extensions.test.tsx +191 -0
  49. package/src/app/components/AppShell.stories.tsx +89 -0
  50. package/src/app/components/AppShell.tsx +358 -0
  51. package/src/app/components/AppShell.zoom.test.tsx +149 -0
  52. package/src/app/components/Breadcrumb.stories.tsx +19 -0
  53. package/src/app/components/Breadcrumb.tsx +45 -0
  54. package/src/app/components/MenuBar.stories.tsx +28 -0
  55. package/src/app/components/MenuBar.tsx +44 -0
  56. package/src/app/components/PaneGrid.stories.tsx +84 -0
  57. package/src/app/components/PaneGrid.tsx +128 -0
  58. package/src/app/components/PanelColumn.stories.tsx +25 -0
  59. package/src/app/components/PanelColumn.tsx +93 -0
  60. package/src/app/components/ShellHeader.stories.tsx +50 -0
  61. package/src/app/components/ShellHeader.tsx +137 -0
  62. package/src/app/components/WorkspacePanels.stories.tsx +36 -0
  63. package/src/app/components/WorkspacePanels.tsx +121 -0
  64. package/src/app/components/pages/Workspace.stories.tsx +181 -0
  65. package/src/app/intent-dispatch.test.ts +729 -0
  66. package/src/app/intent-dispatch.ts +713 -0
  67. package/src/app/launch-layout.test.ts +78 -0
  68. package/src/app/launch-layout.ts +57 -0
  69. package/src/app/ready-signal.test.ts +85 -0
  70. package/src/app/ready-signal.ts +54 -0
  71. package/src/app/run.ts +111 -0
  72. package/src/{serve.test.ts → app/serve.test.ts} +28 -12
  73. package/src/app/serve.ts +111 -0
  74. package/src/app/version.ts +5 -0
  75. package/src/appearance/components/theme-context.stories.tsx +46 -0
  76. package/src/appearance/components/theme-context.tsx +37 -0
  77. package/src/appearance/theme-presets.test.ts +51 -0
  78. package/src/appearance/theme-presets.ts +219 -0
  79. package/src/appearance/theme.ts +112 -0
  80. package/src/appearance/visual/contrast.test.ts +35 -0
  81. package/src/appearance/visual/contrast.ts +33 -0
  82. package/src/diff/components/AnnotatableFileView.stories.tsx +42 -0
  83. package/src/diff/components/AnnotatableFileView.test.tsx +160 -0
  84. package/src/diff/components/AnnotatableFileView.tsx +142 -0
  85. package/src/diff/components/BareWorkbenchFileView.stories.tsx +27 -0
  86. package/src/diff/components/BareWorkbenchFileView.tsx +63 -0
  87. package/src/diff/components/DiffContentView.scroll.test.tsx +443 -0
  88. package/src/diff/components/DiffContentView.split.test.tsx +140 -0
  89. package/src/diff/components/DiffContentView.stories.tsx +80 -0
  90. package/src/diff/components/DiffContentView.syntax.test.tsx +85 -0
  91. package/src/diff/components/DiffContentView.tsx +1080 -0
  92. package/src/diff/components/WalkWizard.stories.tsx +100 -0
  93. package/src/diff/components/WalkWizard.tsx +146 -0
  94. package/src/diff/components/diff-content-view-layout.test.ts +198 -0
  95. package/src/diff/components/diff-content-view-layout.ts +87 -0
  96. package/src/diff/diff-fold.test.ts +78 -0
  97. package/src/diff/diff-fold.ts +130 -0
  98. package/src/diff/diff-hunk-curate.test.ts +315 -0
  99. package/src/diff/diff-hunk-curate.ts +155 -0
  100. package/src/diff/diff-intraline.test.ts +239 -0
  101. package/src/diff/diff-intraline.ts +244 -0
  102. package/src/diff/diff-syntax.test.ts +125 -0
  103. package/src/diff/diff-syntax.ts +194 -0
  104. package/src/diff/split-diff.test.ts +110 -0
  105. package/src/diff/split-diff.ts +150 -0
  106. package/src/diff/view-diff.test.ts +325 -0
  107. package/src/diff/view-diff.ts +540 -0
  108. package/src/diff/walk.test.ts +121 -0
  109. package/src/diff/walk.ts +76 -0
  110. package/src/index.ts +32 -2
  111. package/src/integrations/attribution.test.ts +31 -0
  112. package/src/integrations/attribution.ts +44 -0
  113. package/src/integrations/client-extension-registry.test.tsx +61 -0
  114. package/src/integrations/client-extension-registry.ts +163 -0
  115. package/src/integrations/clipboard.test.ts +23 -0
  116. package/src/integrations/clipboard.ts +34 -0
  117. package/src/integrations/components/ExtensionZone.stories.tsx +28 -0
  118. package/src/integrations/components/ExtensionZone.tsx +145 -0
  119. package/src/integrations/components/ShareDialog.agent.test.tsx +52 -0
  120. package/src/integrations/components/ShareDialog.stories.tsx +121 -0
  121. package/src/integrations/components/ShareDialog.tsx +602 -0
  122. package/src/integrations/components/share-dialog-store.ts +59 -0
  123. package/src/integrations/editor.test.ts +149 -0
  124. package/src/integrations/editor.ts +163 -0
  125. package/src/integrations/github-identity.test.ts +59 -0
  126. package/src/integrations/github-identity.ts +42 -0
  127. package/src/integrations/herdr.test.ts +43 -0
  128. package/src/integrations/herdr.ts +40 -0
  129. package/src/integrations/integrations.test.ts +41 -0
  130. package/src/integrations/integrations.ts +32 -0
  131. package/src/integrations/pasted-image.test.ts +67 -0
  132. package/src/integrations/pasted-image.ts +62 -0
  133. package/src/integrations/share.ts +283 -0
  134. package/src/keyboard/components/CompletionOverlay.stories.tsx +37 -0
  135. package/src/keyboard/components/CompletionOverlay.tsx +75 -0
  136. package/src/keyboard/components/HandleChipsInput.stories.tsx +24 -0
  137. package/src/keyboard/components/HandleChipsInput.test.tsx +35 -0
  138. package/src/keyboard/components/HandleChipsInput.tsx +147 -0
  139. package/src/keyboard/components/KeybindsDialog.stories.tsx +31 -0
  140. package/src/keyboard/components/KeybindsDialog.tsx +103 -0
  141. package/src/keyboard/components/NavModeHint.stories.tsx +40 -0
  142. package/src/keyboard/components/NavModeHint.tsx +31 -0
  143. package/src/keyboard/components/SlashComposer.stories.tsx +37 -0
  144. package/src/keyboard/components/SlashComposer.tsx +90 -0
  145. package/src/keyboard/key-bindings.test.ts +156 -0
  146. package/src/keyboard/key-bindings.ts +462 -0
  147. package/src/keyboard/keymap.test.ts +611 -0
  148. package/src/keyboard/keymap.ts +446 -0
  149. package/src/keyboard/skills.test.ts +74 -0
  150. package/src/keyboard/skills.ts +77 -0
  151. package/src/keyboard/slash-palette.test.ts +103 -0
  152. package/src/keyboard/slash-palette.ts +137 -0
  153. package/src/keyboard/thread-chords.test.ts +79 -0
  154. package/src/keyboard/thread-chords.ts +168 -0
  155. package/src/keyboard/use-shared-keyboard.ts +56 -0
  156. package/src/markdown/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  157. package/src/markdown/components/MarkdownGridBlock.stories.tsx +33 -0
  158. package/src/markdown/components/MarkdownGridBlock.tsx +72 -0
  159. package/src/markdown/components/MarkdownThreadEditor.stories.tsx +15 -0
  160. package/src/markdown/components/MarkdownThreadEditor.test.tsx +98 -0
  161. package/src/markdown/components/MarkdownThreadEditor.tsx +165 -0
  162. package/src/markdown/components/ThreadView.selection.test.tsx +954 -0
  163. package/src/markdown/components/ThreadView.slash.test.tsx +146 -0
  164. package/src/markdown/components/ThreadView.stories.tsx +26 -0
  165. package/src/markdown/components/ThreadView.tsx +577 -0
  166. package/src/markdown/mark-runs.ts +125 -0
  167. package/src/markdown/markdown-editor-syntax-style.ts +66 -0
  168. package/src/markdown/markdown-grid.test.ts +101 -0
  169. package/src/markdown/markdown-grid.ts +209 -0
  170. package/src/markdown/markdown-highlight.test.ts +61 -0
  171. package/src/markdown/markdown-highlight.ts +125 -0
  172. package/src/markdown/thread-code-syntax.ts +48 -0
  173. package/src/markdown/use-thread-body-editing.tsx +54 -0
  174. package/src/markdown/view-plan.regression.test.ts +60 -0
  175. package/src/markdown/view-plan.test.ts +545 -0
  176. package/src/markdown/view-plan.ts +1061 -0
  177. package/src/performance/otlp-trace.test.ts +119 -0
  178. package/src/performance/otlp-trace.ts +134 -0
  179. package/src/performance/perf-report.ts +19 -0
  180. package/src/performance/perf-timings.test.ts +18 -0
  181. package/src/performance/perf-timings.ts +40 -0
  182. package/src/prototype/components/PrototypeContentView.stories.tsx +19 -0
  183. package/src/prototype/components/PrototypeContentView.tsx +459 -0
  184. package/src/prototype/prototype-browser.test.ts +81 -0
  185. package/src/prototype/prototype-browser.ts +332 -0
  186. package/src/prototype/prototype-pixels.tsx +35 -0
  187. package/src/settings/components/SettingsDialog.stories.tsx +117 -0
  188. package/src/settings/components/SettingsDialog.tsx +186 -0
  189. package/src/settings/components/SettingsRows.stories.tsx +18 -0
  190. package/src/settings/components/SettingsRows.tsx +112 -0
  191. package/src/settings/components/quick-actions-editor.stories.tsx +58 -0
  192. package/src/settings/components/quick-actions-editor.test.tsx +120 -0
  193. package/src/settings/components/quick-actions-editor.tsx +222 -0
  194. package/src/settings/config-identity.test.ts +78 -0
  195. package/src/settings/config.test.ts +771 -0
  196. package/src/settings/config.ts +722 -0
  197. package/src/settings/use-settings-dialog.test.tsx +172 -0
  198. package/src/settings/use-settings-dialog.tsx +465 -0
  199. package/src/settings/use-thread-harness-setting.test.tsx +87 -0
  200. package/src/settings/use-thread-harness-setting.ts +86 -0
  201. package/src/stories/__snapshots__/stories.test.tsx.snap +2358 -0
  202. package/src/stories/stories-app.tsx +196 -0
  203. package/src/stories/stories.test.tsx +78 -0
  204. package/src/stories/story-fixtures.ts +102 -0
  205. package/src/stories/story-tree.test.ts +108 -0
  206. package/src/stories/story.ts +160 -0
  207. package/src/terminal/ansi-screen.test.ts +28 -0
  208. package/src/terminal/ansi-screen.ts +77 -0
  209. package/src/terminal/components/AnsiScreen.stories.tsx +12 -0
  210. package/src/terminal/components/AnsiScreen.tsx +28 -0
  211. package/src/terminal/components/terminal-pane.test.tsx +40 -0
  212. package/src/terminal/components/terminal-pane.ts +208 -0
  213. package/src/terminal/ghostty-terminal.test.ts +85 -0
  214. package/src/terminal/ghostty-terminal.ts +207 -0
  215. package/src/terminal/kitty-image.test.ts +66 -0
  216. package/src/terminal/kitty-image.ts +140 -0
  217. package/src/terminal/pty.test.ts +81 -0
  218. package/src/terminal/pty.ts +255 -0
  219. package/src/testing/test-support.test.ts +266 -0
  220. package/src/testing/test-support.ts +449 -0
  221. package/src/thread/agent-continuation.test.ts +34 -0
  222. package/src/thread/agent-continuation.ts +13 -0
  223. package/src/thread/agent-transcript.test.ts +161 -0
  224. package/src/thread/agent-transcript.ts +344 -0
  225. package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
  226. package/src/thread/components/AgentConfigControls.test.tsx +153 -0
  227. package/src/thread/components/AgentConfigControls.tsx +121 -0
  228. package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
  229. package/src/thread/components/AgentThreadPane.test.tsx +1248 -0
  230. package/src/thread/components/AgentThreadPane.tsx +458 -0
  231. package/src/thread/components/ThreadFooter.stories.tsx +30 -0
  232. package/src/thread/components/ThreadFooter.test.tsx +49 -0
  233. package/src/thread/components/ThreadFooter.tsx +77 -0
  234. package/src/thread/components/ThreadTree.stories.tsx +46 -0
  235. package/src/thread/components/ThreadTree.test.tsx +41 -0
  236. package/src/thread/components/ThreadTree.tsx +268 -0
  237. package/src/thread/components/thread-tree.test.ts +138 -0
  238. package/src/thread/components/thread-tree.ts +112 -0
  239. package/src/thread/thread-controller.curate.test.ts +504 -0
  240. package/src/thread/thread-controller.derivation.test.ts +62 -0
  241. package/src/thread/thread-controller.live-diff.test.ts +357 -0
  242. package/src/thread/thread-controller.optimistic.test.ts +69 -0
  243. package/src/thread/thread-controller.share.test.ts +618 -0
  244. package/src/thread/thread-controller.switch-cache.test.ts +174 -0
  245. package/src/thread/thread-controller.tree.test.ts +263 -0
  246. package/src/thread/thread-controller.ts +2151 -0
  247. package/src/thread/tree-view.test.ts +128 -0
  248. package/src/thread/tree-view.ts +150 -0
  249. package/src/thread/use-thread-agent.test.tsx +209 -0
  250. package/src/thread/use-thread-agent.ts +122 -0
  251. package/src/ui/components/CodeBlock.stories.tsx +42 -0
  252. package/src/ui/components/CodeBlock.tsx +82 -0
  253. package/src/ui/components/ConfirmCard.stories.tsx +37 -0
  254. package/src/ui/components/ConfirmCard.tsx +151 -0
  255. package/src/ui/components/ConfirmDialog.stories.tsx +21 -0
  256. package/src/ui/components/ConfirmDialog.tsx +72 -0
  257. package/src/ui/components/OverlayScrollbar.stories.tsx +33 -0
  258. package/src/ui/components/OverlayScrollbar.test.tsx +142 -0
  259. package/src/ui/components/OverlayScrollbar.tsx +134 -0
  260. package/src/ui/components/PromptDialog.stories.tsx +36 -0
  261. package/src/ui/components/PromptDialog.test.tsx +65 -0
  262. package/src/ui/components/PromptDialog.tsx +101 -0
  263. package/src/ui/components/RootOverlay.stories.tsx +45 -0
  264. package/src/ui/components/RootOverlay.tsx +50 -0
  265. package/src/ui/components/ScrollArea.stories.tsx +20 -0
  266. package/src/ui/components/ScrollArea.tsx +63 -0
  267. package/src/ui/components/Toast.stories.tsx +19 -0
  268. package/src/ui/components/Toast.tsx +54 -0
  269. package/src/ui/components/Tooltip.stories.tsx +26 -0
  270. package/src/ui/components/Tooltip.tsx +93 -0
  271. package/src/ui/components/menu-control.stories.tsx +35 -0
  272. package/src/ui/components/menu-control.tsx +43 -0
  273. package/src/ui/components/primitives/Button.stories.tsx +32 -0
  274. package/src/ui/components/primitives/Button.tsx +58 -0
  275. package/src/ui/components/primitives/Card.stories.tsx +25 -0
  276. package/src/ui/components/primitives/Card.tsx +62 -0
  277. package/src/ui/components/primitives/Dialog.stories.tsx +24 -0
  278. package/src/ui/components/primitives/Dialog.test.tsx +38 -0
  279. package/src/ui/components/primitives/Dialog.tsx +69 -0
  280. package/src/ui/components/primitives/DialogActions.stories.tsx +20 -0
  281. package/src/ui/components/primitives/DialogActions.tsx +39 -0
  282. package/src/ui/components/primitives/IconButton.stories.tsx +36 -0
  283. package/src/ui/components/primitives/IconButton.tsx +58 -0
  284. package/src/ui/components/primitives/StatusBar.stories.tsx +11 -0
  285. package/src/ui/components/primitives/StatusBar.tsx +23 -0
  286. package/src/ui/components/primitives/Switch.stories.tsx +17 -0
  287. package/src/ui/components/primitives/Switch.tsx +32 -0
  288. package/src/ui/components/primitives/Tabs.stories.tsx +29 -0
  289. package/src/ui/components/primitives/Tabs.tsx +102 -0
  290. package/src/ui/components/primitives/Toolbar.stories.tsx +17 -0
  291. package/src/ui/components/primitives/Toolbar.tsx +14 -0
  292. package/src/ui/components/primitives/Tree.stories.tsx +124 -0
  293. package/src/ui/components/primitives/Tree.tsx +137 -0
  294. package/src/ui/components/primitives/frame.test.ts +12 -0
  295. package/src/ui/components/primitives/frame.ts +17 -0
  296. package/src/ui/components/primitives/icons.ts +61 -0
  297. package/src/ui/components/primitives/tree-model.test.ts +99 -0
  298. package/src/ui/components/primitives/tree-model.ts +144 -0
  299. package/src/ui/components/syntax-highlight.ts +154 -0
  300. package/src/ui/components/truncate-text.ts +7 -0
  301. package/src/ui/components/truncate-title.test.ts +31 -0
  302. package/src/ui/components/truncate-title.ts +16 -0
  303. package/src/ui/use-frame-measure.ts +88 -0
  304. package/src/ui/use-terminal-virtualizer.ts +286 -0
  305. package/src/workbench/components/ChangesColumn.stories.tsx +44 -0
  306. package/src/workbench/components/ChangesColumn.tsx +210 -0
  307. package/src/workbench/components/ChangesFileTree.test.tsx +184 -0
  308. package/src/workbench/components/EditorGrid.split.test.tsx +119 -0
  309. package/src/workbench/components/EditorGrid.stories.tsx +86 -0
  310. package/src/workbench/components/EditorGrid.tabs.test.tsx +118 -0
  311. package/src/workbench/components/EditorGrid.tsx +552 -0
  312. package/src/workbench/components/EditorGrid.zoom.test.tsx +132 -0
  313. package/src/workbench/components/FileContentsView.stories.tsx +16 -0
  314. package/src/workbench/components/FileContentsView.test.tsx +29 -0
  315. package/src/workbench/components/FileContentsView.tsx +104 -0
  316. package/src/workbench/components/GridTabContent.actions.test.tsx +151 -0
  317. package/src/workbench/components/GridTabContent.stories.tsx +59 -0
  318. package/src/workbench/components/GridTabContent.tsx +195 -0
  319. package/src/workbench/components/ProjectTreeView.stories.tsx +25 -0
  320. package/src/workbench/components/ProjectTreeView.test.tsx +239 -0
  321. package/src/workbench/components/ProjectTreeView.tsx +159 -0
  322. package/src/workbench/components/TreePane.stories.tsx +106 -0
  323. package/src/workbench/components/TreePane.tsx +157 -0
  324. package/src/workbench/components/WelcomePlayground.stories.tsx +12 -0
  325. package/src/workbench/components/WelcomePlayground.test.tsx +83 -0
  326. package/src/workbench/components/WelcomePlayground.tsx +138 -0
  327. package/src/workbench/components/editor-grid.test.ts +184 -0
  328. package/src/workbench/components/editor-grid.ts +284 -0
  329. package/src/workbench/components/file-tree.test.ts +60 -0
  330. package/src/workbench/components/file-tree.ts +114 -0
  331. package/src/workbench/use-changes-workbench.ts +210 -0
  332. package/src/workbench/use-remember-layout.ts +33 -0
  333. package/src/workbench/use-repo-changes.ts +93 -0
  334. package/src/workbench/workbench-snapshot.test.ts +99 -0
  335. package/src/workbench/workbench-snapshot.ts +59 -0
  336. package/src/App.diff.test.tsx +0 -99
  337. package/src/App.export.test.tsx +0 -88
  338. package/src/App.plan-review.test.tsx +0 -259
  339. package/src/App.readonly.test.tsx +0 -148
  340. package/src/App.rendering.test.tsx +0 -108
  341. package/src/App.submit-confirm.test.tsx +0 -183
  342. package/src/App.test.tsx +0 -218
  343. package/src/App.tsx +0 -1249
  344. package/src/config.test.ts +0 -116
  345. package/src/config.ts +0 -150
  346. package/src/editor.ts +0 -34
  347. package/src/herdr.test.ts +0 -68
  348. package/src/herdr.ts +0 -34
  349. package/src/keymap.test.ts +0 -270
  350. package/src/keymap.ts +0 -173
  351. package/src/run.ts +0 -30
  352. package/src/serve.ts +0 -75
  353. package/src/session-controller.ts +0 -398
  354. package/src/syntax.test.ts +0 -41
  355. package/src/syntax.ts +0 -88
  356. package/src/theme.ts +0 -46
  357. package/src/view-diff.test.ts +0 -55
  358. package/src/view-diff.ts +0 -86
  359. package/src/view.test.ts +0 -157
  360. package/src/view.ts +0 -364
@@ -0,0 +1,266 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createElement, type ReactElement } from "react";
6
+ import type { KeyEvent, MouseEvent } from "@opentui/core";
7
+ import type { TestRendererSetup } from "@opentui/core/testing";
8
+ import { useKeyboard } from "@opentui/react";
9
+ import { testRender } from "@opentui/react/test-utils";
10
+ import { loadConfig } from "../settings/config";
11
+ import {
12
+ clickText,
13
+ dragText,
14
+ frameRow,
15
+ isolateUserConfig,
16
+ locateText,
17
+ locateTextInFrame,
18
+ pressKey,
19
+ waitForText,
20
+ type FrameLocation,
21
+ } from "./test-support";
22
+
23
+ describe("isolateUserConfig", () => {
24
+ test("loadConfig sees defaults while isolated and the env is restored after", () => {
25
+ // Arrange
26
+ const home = mkdtempSync(join(tmpdir(), "cueloop-isolate-"));
27
+ const userConfigPath = join(home, "config.toml");
28
+
29
+ writeFileSync(userConfigPath, "[ui]\nauto_close = 5\n");
30
+ const priorEnv = process.env.CUELOOP_CONFIG;
31
+
32
+ process.env.CUELOOP_CONFIG = userConfigPath;
33
+
34
+ try {
35
+ // Act
36
+ const restoreUserConfig = isolateUserConfig(home);
37
+ const isolated = loadConfig({ repoRoot: home });
38
+
39
+ restoreUserConfig();
40
+ const restored = loadConfig({ repoRoot: home });
41
+
42
+ // Assert
43
+ expect(isolated.ui.autoClose).toBe("off");
44
+ expect(process.env.CUELOOP_CONFIG).toBe(userConfigPath);
45
+ expect(restored.ui.autoClose).toBe(5);
46
+ } finally {
47
+ if (priorEnv === undefined) delete process.env.CUELOOP_CONFIG;
48
+ else process.env.CUELOOP_CONFIG = priorEnv;
49
+
50
+ rmSync(home, { recursive: true, force: true });
51
+ }
52
+ });
53
+
54
+ test("restore removes the variable when nothing was set before", () => {
55
+ // Arrange
56
+ const home = mkdtempSync(join(tmpdir(), "cueloop-isolate-"));
57
+ const priorEnv = process.env.CUELOOP_CONFIG;
58
+
59
+ delete process.env.CUELOOP_CONFIG;
60
+
61
+ try {
62
+ // Act
63
+ const restoreUserConfig = isolateUserConfig(home);
64
+ const pointedAt = process.env.CUELOOP_CONFIG ?? "";
65
+
66
+ restoreUserConfig();
67
+
68
+ // Assert
69
+ expect(pointedAt).toBe(join(home, "no-config.toml"));
70
+ expect(process.env.CUELOOP_CONFIG).toBeUndefined();
71
+ } finally {
72
+ if (priorEnv !== undefined) process.env.CUELOOP_CONFIG = priorEnv;
73
+
74
+ rmSync(home, { recursive: true, force: true });
75
+ }
76
+ });
77
+ });
78
+
79
+ interface RecordedKey {
80
+ name: string;
81
+ ctrl: boolean;
82
+ shift: boolean;
83
+ meta: boolean;
84
+ }
85
+
86
+ interface RecordedMouseEvent {
87
+ type: "down" | "up";
88
+ x: number;
89
+ y: number;
90
+ }
91
+
92
+ interface FixtureLog {
93
+ keys: RecordedKey[];
94
+ mouseEvents: RecordedMouseEvent[];
95
+ pressZoneClicks: number[];
96
+ }
97
+
98
+ /**
99
+ * A minimal interaction surface: one keyboard recorder, a root box recording
100
+ * down/up coordinates, and stacked text rows at known positions.
101
+ */
102
+ function InteractionFixture(properties: { log: FixtureLog }): ReactElement {
103
+ const { log } = properties;
104
+
105
+ useKeyboard((key: KeyEvent) => {
106
+ log.keys.push({ name: key.name, ctrl: key.ctrl, shift: key.shift, meta: key.meta });
107
+ });
108
+
109
+ return createElement(
110
+ "box",
111
+ {
112
+ width: "100%",
113
+ height: "100%",
114
+ flexDirection: "column",
115
+ onMouseDown: (event: MouseEvent) => {
116
+ log.mouseEvents.push({ type: "down", x: event.x, y: event.y });
117
+ },
118
+ onMouseUp: (event: MouseEvent) => {
119
+ log.mouseEvents.push({ type: "up", x: event.x, y: event.y });
120
+ },
121
+ },
122
+ createElement("text", { content: "header line" }),
123
+ createElement("text", { content: " shifted needle" }),
124
+ createElement("text", { content: " drag from here" }),
125
+ createElement("text", { content: " drop to here" }),
126
+ createElement("text", {
127
+ content: "press zone",
128
+ onMouseDown: (event: MouseEvent) => {
129
+ log.pressZoneClicks.push(event.x);
130
+ },
131
+ }),
132
+ );
133
+ }
134
+
135
+ async function renderFixture(): Promise<{ setup: TestRendererSetup; log: FixtureLog }> {
136
+ const log: FixtureLog = { keys: [], mouseEvents: [], pressZoneClicks: [] };
137
+ const setup = await testRender(createElement(InteractionFixture, { log }), {
138
+ width: 40,
139
+ height: 8,
140
+ });
141
+
142
+ await waitForText(setup, "press zone");
143
+
144
+ return { setup, log };
145
+ }
146
+
147
+ describe("pressKey", () => {
148
+ test("a ctrl+letter chord reaches the keyboard handler with ctrl set", async () => {
149
+ // Arrange
150
+ const { setup, log } = await renderFixture();
151
+
152
+ // Act
153
+ await pressKey(setup, "o", { ctrl: true });
154
+
155
+ // Assert
156
+ expect(log.keys).toEqual([{ name: "o", ctrl: true, shift: false, meta: false }]);
157
+ });
158
+
159
+ test("a named key with a modifier arrives as the modified key", async () => {
160
+ // Arrange
161
+ const { setup, log } = await renderFixture();
162
+
163
+ // Act
164
+ await pressKey(setup, "ARROW_RIGHT", { shift: true });
165
+
166
+ // Assert
167
+ expect(log.keys).toEqual([{ name: "right", ctrl: false, shift: true, meta: false }]);
168
+ });
169
+ });
170
+
171
+ describe("locateText", () => {
172
+ test("returns the 0-based row and start column of the first occurrence", async () => {
173
+ // Arrange
174
+ const { setup } = await renderFixture();
175
+
176
+ // Act
177
+ const located = locateText(setup, "shifted needle");
178
+
179
+ // Assert
180
+ expect(located).toEqual({ row: 1, column: 3 });
181
+ });
182
+
183
+ test("a miss throws with the needle and the full frame in the message", async () => {
184
+ // Arrange
185
+ const { setup } = await renderFixture();
186
+
187
+ // Act
188
+ const locateMissing = (): FrameLocation => locateText(setup, "absent needle");
189
+
190
+ // Assert
191
+ expect(locateMissing).toThrow('"absent needle"');
192
+ expect(locateMissing).toThrow("header line");
193
+ });
194
+ });
195
+
196
+ describe("frameRow", () => {
197
+ test("returns rows that reflect the vertical layout", async () => {
198
+ // Arrange
199
+ const { setup } = await renderFixture();
200
+
201
+ // Act / Assert
202
+ expect(frameRow(setup, "header line")).toBe(0);
203
+ expect(frameRow(setup, "drop to here")).toBe(3);
204
+ expect(frameRow(setup, "press zone")).toBe(4);
205
+ });
206
+ });
207
+
208
+ describe("clickText", () => {
209
+ test("lands the click on the element rendering the needle", async () => {
210
+ // Arrange
211
+ const { setup, log } = await renderFixture();
212
+
213
+ // Act
214
+ await clickText(setup, "press zone");
215
+
216
+ // Assert
217
+ expect(log.pressZoneClicks).toEqual([0]);
218
+ });
219
+
220
+ test("charOffset shifts the click within the needle", async () => {
221
+ // Arrange
222
+ const { setup, log } = await renderFixture();
223
+
224
+ // Act
225
+ await clickText(setup, "press zone", 6);
226
+
227
+ // Assert
228
+ expect(log.pressZoneClicks).toEqual([6]);
229
+ });
230
+ });
231
+
232
+ describe("dragText", () => {
233
+ test("delivers the down at the from-text and the up at the to-text", async () => {
234
+ // Arrange
235
+ const { setup, log } = await renderFixture();
236
+
237
+ // Act
238
+ await dragText(setup, "drag from here", "drop to here");
239
+
240
+ // Assert
241
+ expect(log.mouseEvents).toContainEqual({ type: "down", x: 2, y: 2 });
242
+ expect(log.mouseEvents).toContainEqual({ type: "up", x: 5, y: 3 });
243
+ });
244
+
245
+ test("an end offset lands the up that many characters past the to-text", async () => {
246
+ // Arrange
247
+ const { setup, log } = await renderFixture();
248
+
249
+ // Act
250
+ await dragText(setup, "drag from here", "drop to here", 4);
251
+
252
+ // Assert
253
+ expect(log.mouseEvents).toContainEqual({ type: "up", x: 9, y: 3 });
254
+ });
255
+ });
256
+
257
+ describe("locateTextInFrame", () => {
258
+ test("finds the 0-based row and column of the first match, null when absent", () => {
259
+ // Arrange
260
+ const frame = "header\n ● draft here\nfooter";
261
+
262
+ // Act + Assert
263
+ expect(locateTextInFrame(frame, "draft")).toEqual({ row: 1, column: 4 });
264
+ expect(locateTextInFrame(frame, "missing")).toBeNull();
265
+ });
266
+ });
@@ -0,0 +1,449 @@
1
+ /**
2
+ * Shared driving helpers for the virtual-terminal App suites: an input settles
3
+ * when its painted frame stops changing across consecutive event-loop turns
4
+ * (macrotask yield, render pass, visual-idle wait per turn) - no fixed-duration
5
+ * sleeps. Waits on daemon round-trips go through waitForFrame/waitFor with
6
+ * generous pass budgets.
7
+ */
8
+
9
+ import { join } from "node:path";
10
+ import { cloneElement, isValidElement, type ReactNode } from "react";
11
+ import type { TestRendererOptions, TestRendererSetup } from "@opentui/core/testing";
12
+ import { testRender } from "@opentui/react/test-utils";
13
+ import type { AppProps } from "../app/App";
14
+
15
+ /**
16
+ * A probe for the App's ready signal in an in-process suite: pass `onReady`
17
+ * to the App and await `ready` before the first interaction, instead of
18
+ * waiting for a piece of text and hoping the keyboard is bound by then.
19
+ */
20
+ export interface AppReadyProbe {
21
+ onReady: () => void;
22
+ ready: Promise<void>;
23
+ }
24
+
25
+ export function appReadyProbe(): AppReadyProbe {
26
+ let resolve: () => void = () => {};
27
+ const ready = new Promise<void>((done) => {
28
+ resolve = done;
29
+ });
30
+
31
+ return { onReady: () => resolve(), ready };
32
+ }
33
+
34
+ /**
35
+ * Boot an App element and resolve once it has fired its ready signal: the
36
+ * first usable screen is painted and its keyboard handlers are subscribed.
37
+ * Every App suite starts here so no boot waits on a piece of text and hopes.
38
+ */
39
+ export async function renderReadyApp(
40
+ element: ReactNode,
41
+ options: TestRendererOptions,
42
+ ): Promise<TestRendererSetup> {
43
+ if (!isValidElement<AppProps>(element)) throw new Error("renderReadyApp needs an App element");
44
+
45
+ const probe = appReadyProbe();
46
+ const setup = await testRender(cloneElement(element, { onReady: probe.onReady }), options);
47
+
48
+ allowEventLoopUpdates();
49
+ await probe.ready;
50
+
51
+ return setup;
52
+ }
53
+
54
+ /**
55
+ * The CUELOOP_CONFIG path that isolates a test from the developer's real user
56
+ * config: a file inside the test home that does not exist unless the test
57
+ * writes it. Subprocess harnesses set this in the child env; in-process suites
58
+ * use `isolateUserConfig`.
59
+ */
60
+ export function isolatedUserConfigPath(home: string, fileName = "no-config.toml"): string {
61
+ return join(home, fileName);
62
+ }
63
+
64
+ /**
65
+ * Isolate the user config: point CUELOOP_CONFIG into the test home so
66
+ * loadConfig never reads the developer's real ~/.config/cueloop (a persisted
67
+ * review_state would change what char frames render locally while CI stays
68
+ * clean). The default file name does not exist, so loadConfig returns
69
+ * defaults; pass a file name for suites that write and assert a config of
70
+ * their own. Call in beforeEach; invoke the returned restore in afterEach.
71
+ */
72
+ export function isolateUserConfig(home: string, fileName?: string): () => void {
73
+ const priorUserConfig = process.env.CUELOOP_CONFIG;
74
+
75
+ process.env.CUELOOP_CONFIG = isolatedUserConfigPath(home, fileName);
76
+
77
+ return () => {
78
+ if (priorUserConfig === undefined) delete process.env.CUELOOP_CONFIG;
79
+ else process.env.CUELOOP_CONFIG = priorUserConfig;
80
+ };
81
+ }
82
+
83
+ declare global {
84
+ var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
85
+ }
86
+
87
+ /** The harness drives the real event loop by design; keep React's act warning off. */
88
+ export function allowEventLoopUpdates(): void {
89
+ globalThis.IS_REACT_ACT_ENVIRONMENT = false;
90
+ }
91
+
92
+ /** Turns of the event loop a frame must survive unchanged before an input counts as settled. */
93
+ const SETTLE_QUIET_TURNS = 2;
94
+
95
+ /** Upper bound on settle turns, so a surface that keeps changing never stalls a test. */
96
+ const SETTLE_MAX_TURNS = 40;
97
+
98
+ /** The painted frame with its colors, so a caret cell (background only) counts as a change. */
99
+ function paintedFrame(setup: TestRendererSetup): string {
100
+ return JSON.stringify(
101
+ setup
102
+ .captureSpans()
103
+ .lines.map((line) =>
104
+ line.spans.map((span) => [span.text, span.fg?.toInts(), span.bg?.toInts()]),
105
+ ),
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Wait for an input to land: React commits its state on its own scheduler, which the renderer's
111
+ * idle check cannot see, so on a slow machine a single idle wait returns between the key and its
112
+ * paint. Instead, run turns of the loop until the painted frame is unchanged across
113
+ * `SETTLE_QUIET_TURNS` consecutive turns.
114
+ */
115
+ export async function settle(setup: TestRendererSetup): Promise<void> {
116
+ allowEventLoopUpdates();
117
+ let previous = "";
118
+ let quietTurns = 0;
119
+
120
+ for (let turn = 0; turn < SETTLE_MAX_TURNS; turn++) {
121
+ if (setup.renderer.isDestroyed) return;
122
+
123
+ await new Promise((resolve) => setTimeout(resolve, 0));
124
+
125
+ if (setup.renderer.isDestroyed) return;
126
+
127
+ await setup.renderOnce();
128
+ await setup.waitForVisualIdle();
129
+
130
+ if (setup.renderer.isDestroyed) return;
131
+
132
+ if (setup.renderer.getSchedulerState().isRendering) {
133
+ await setup.renderOnce();
134
+ }
135
+
136
+ const frame = paintedFrame(setup);
137
+
138
+ quietTurns = frame === previous ? quietTurns + 1 : 0;
139
+
140
+ if (quietTurns >= SETTLE_QUIET_TURNS) return;
141
+
142
+ previous = frame;
143
+ }
144
+ }
145
+
146
+ /** Drive one key press: letters type as text; named keys use KeyCodes ids. */
147
+ export async function press(setup: TestRendererSetup, key: string): Promise<void> {
148
+ if (key === "enter") setup.mockInput.pressKey("RETURN");
149
+ else if (key === "escape") setup.mockInput.pressKey("ESCAPE");
150
+ else if (key === "backspace") setup.mockInput.pressKey("BACKSPACE");
151
+ else if (key === "left") setup.mockInput.pressKey("ARROW_LEFT");
152
+ else if (key === "right") setup.mockInput.pressKey("ARROW_RIGHT");
153
+ else if (key === "up") setup.mockInput.pressKey("ARROW_UP");
154
+ else if (key === "down") setup.mockInput.pressKey("ARROW_DOWN");
155
+ else return typeText(setup, key);
156
+
157
+ await settle(setup);
158
+ }
159
+
160
+ export async function typeText(setup: TestRendererSetup, text: string): Promise<void> {
161
+ await setup.mockInput.typeText(text);
162
+ await settle(setup);
163
+ }
164
+
165
+ /** Drive one key press with modifiers: KeyCodes names ("ARROW_RIGHT") or single letters (ctrl+o). */
166
+ export async function pressKey(
167
+ setup: TestRendererSetup,
168
+ key: string,
169
+ modifiers?: { shift?: boolean; ctrl?: boolean; meta?: boolean },
170
+ ): Promise<void> {
171
+ setup.mockInput.pressKey(key, modifiers);
172
+ await settle(setup);
173
+ }
174
+
175
+ /**
176
+ * Run a thread nav-mode command: esc drops the surface into nav mode, then a bare
177
+ * key acts (a letter, or a named key like "enter"/"backspace"). The caret and any
178
+ * held selection carry into nav, so the command lands where the caret is.
179
+ */
180
+ export async function navCommand(setup: TestRendererSetup, key: string): Promise<void> {
181
+ await press(setup, "escape");
182
+ await new Promise((resolve) => setTimeout(resolve, 40));
183
+ await settle(setup);
184
+
185
+ // a single letter is a discrete keypress (nav command); named keys route through press
186
+ if (key.length === 1) await pressKey(setup, key);
187
+ else await press(setup, key);
188
+ }
189
+
190
+ /** A 0-based cell position within a captured char frame. */
191
+ export interface FrameLocation {
192
+ row: number;
193
+ column: number;
194
+ }
195
+
196
+ /** Locate the first line of a text frame containing the needle; null when absent. */
197
+ export function locateTextInFrame(frame: string, needle: string): FrameLocation | null {
198
+ for (const [row, line] of frame.split("\n").entries()) {
199
+ const column = line.indexOf(needle);
200
+
201
+ if (column !== -1) return { row, column };
202
+ }
203
+
204
+ return null;
205
+ }
206
+
207
+ /** Locate the first visual line containing the needle: 0-based row and start column. */
208
+ export function locateText(setup: TestRendererSetup, needle: string): FrameLocation {
209
+ const frame = setup.captureCharFrame();
210
+ const location = locateTextInFrame(frame, needle);
211
+
212
+ if (location === null) {
213
+ throw new Error(`locateText ${JSON.stringify(needle)} not found.\nframe:\n${frame}`);
214
+ }
215
+
216
+ return location;
217
+ }
218
+
219
+ /** The 0-based visual row of the needle - for relative vertical-layout assertions. */
220
+ export function frameRow(setup: TestRendererSetup, needle: string): number {
221
+ return locateText(setup, needle).row;
222
+ }
223
+
224
+ /** Click the first on-screen occurrence of the needle, offset in characters from its start. */
225
+ export async function clickText(
226
+ setup: TestRendererSetup,
227
+ needle: string,
228
+ charOffset = 0,
229
+ ): Promise<void> {
230
+ const { row, column } = locateText(setup, needle);
231
+
232
+ await setup.mockMouse.click(column + charOffset, row);
233
+ await settle(setup);
234
+ }
235
+
236
+ /**
237
+ * Drag from the first occurrence of one needle to the first occurrence of
238
+ * another, `toCharOffset` characters past the second needle's start - so a
239
+ * character-precise selection can end inside or right after a word.
240
+ */
241
+ export async function dragText(
242
+ setup: TestRendererSetup,
243
+ fromNeedle: string,
244
+ toNeedle: string,
245
+ toCharOffset = 0,
246
+ ): Promise<void> {
247
+ const from = locateText(setup, fromNeedle);
248
+ const to = locateText(setup, toNeedle);
249
+
250
+ await setup.mockMouse.drag(from.column, from.row, to.column + toCharOffset, to.row);
251
+ await settle(setup);
252
+ }
253
+
254
+ // Below the 60s per-test budget so a genuinely stuck wait throws its frame
255
+ // error (useful) before bun's bare "timed out" fires. Generous because a
256
+ // contended CI runner starves the render loop and daemon round-trips.
257
+ const WAIT_DEADLINE_MS = 45_000;
258
+
259
+ /** One macrotask turn: hands the event loop back so the in-process daemon can
260
+ * read its socket and apply the write before the next poll. Tight render-pass
261
+ * bursts starve that read on a contended CI runner - the round-trip then never
262
+ * lands and the wait hangs the whole budget. */
263
+ function yieldEventLoop(): Promise<void> {
264
+ return new Promise((resolve) => setTimeout(resolve, 2));
265
+ }
266
+
267
+ /** Log a still-waiting line at most this often, so only genuinely slow (i.e.
268
+ * flaking) waits are noisy; fast waits stay silent. */
269
+ const PROGRESS_LOG_MS = 4000;
270
+
271
+ /** The last render frame's tail, so a progress line shows what is on screen. */
272
+ function frameTail(frame: string, rows = 6): string {
273
+ return frame.split("\n").slice(-rows).join("\n");
274
+ }
275
+
276
+ /**
277
+ * Poll gently: one render pass, one frame check, one macrotask yield. No
278
+ * multi-pass bursts, so the daemon (same event loop) always gets a turn to
279
+ * process IO between checks. The deadline bounds the whole wait. Slow waits log
280
+ * progress (poll count + frame-change count) so a CI stall shows whether the
281
+ * render froze or the content simply never arrived.
282
+ */
283
+ async function waitForFramePredicate(
284
+ setup: TestRendererSetup,
285
+ predicate: (frame: string) => boolean,
286
+ label: string,
287
+ ): Promise<string> {
288
+ allowEventLoopUpdates();
289
+ const start = Date.now();
290
+ const deadline = start + WAIT_DEADLINE_MS;
291
+ let polls = 0;
292
+ let frameChanges = 0;
293
+ let lastFrame = "";
294
+ let lastLog = start;
295
+
296
+ for (;;) {
297
+ if (setup.renderer.isDestroyed) {
298
+ throw new Error(
299
+ `waitFor ${label} failed: renderer was destroyed before frame matched.\nlast frame:\n${lastFrame}`,
300
+ );
301
+ }
302
+
303
+ await setup.renderOnce();
304
+
305
+ if (setup.renderer.isDestroyed) {
306
+ throw new Error(
307
+ `waitFor ${label} failed: renderer was destroyed before frame matched.\nlast frame:\n${lastFrame}`,
308
+ );
309
+ }
310
+
311
+ // Retry while a render still holds the buffer lease.
312
+ // eslint-disable-next-line no-await-in-loop
313
+ const frame = await captureSettledFrame(setup);
314
+
315
+ polls++;
316
+
317
+ if (frame !== lastFrame) {
318
+ frameChanges++;
319
+ lastFrame = frame;
320
+ }
321
+
322
+ if (predicate(frame)) return frame;
323
+
324
+ const now = Date.now();
325
+
326
+ if (now > deadline) {
327
+ const frozen = frameChanges <= 1 ? " - render never advanced (frozen)" : "";
328
+
329
+ throw new Error(
330
+ `waitFor ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
331
+ );
332
+ }
333
+
334
+ if (now - lastLog >= PROGRESS_LOG_MS) {
335
+ lastLog = now;
336
+ console.error(
337
+ `[waitFor] still waiting for ${label} - ${now - start}ms, ${polls} polls, ${frameChanges} frame changes\n${frameTail(frame)}`,
338
+ );
339
+ }
340
+
341
+ await yieldEventLoop();
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Capture a frame, retrying while OpenTUI still holds the buffer lease.
347
+ * A render in progress answers the capture with OutputBusy.
348
+ */
349
+ export async function captureSettledFrame(
350
+ setup: { captureCharFrame(): string },
351
+ deadline = Date.now() + 1_000,
352
+ ): Promise<string> {
353
+ try {
354
+ return setup.captureCharFrame();
355
+ } catch (error) {
356
+ const busy = error instanceof Error && error.message.includes("OutputBusy");
357
+
358
+ if (!busy || Date.now() >= deadline) throw error;
359
+
360
+ await new Promise((resolve) => setTimeout(resolve, 15));
361
+
362
+ return captureSettledFrame(setup, deadline);
363
+ }
364
+ }
365
+
366
+ /** Wait until a state predicate holds (daemon round-trips included). */
367
+ export async function waitForState(
368
+ setup: TestRendererSetup,
369
+ predicate: () => boolean,
370
+ label = "state",
371
+ ): Promise<void> {
372
+ allowEventLoopUpdates();
373
+ const start = Date.now();
374
+ const deadline = start + WAIT_DEADLINE_MS;
375
+ let polls = 0;
376
+ let frameChanges = 0;
377
+ let lastFrame = "";
378
+ let lastLog = start;
379
+
380
+ for (;;) {
381
+ if (setup.renderer.isDestroyed) {
382
+ if (predicate()) return;
383
+
384
+ throw new Error(
385
+ `waitForState ${label} failed: renderer was destroyed before condition matched.\nlast frame:\n${lastFrame}`,
386
+ );
387
+ }
388
+
389
+ await setup.renderOnce();
390
+
391
+ if (setup.renderer.isDestroyed) {
392
+ if (predicate()) return;
393
+
394
+ throw new Error(
395
+ `waitForState ${label} failed: renderer was destroyed before condition matched.\nlast frame:\n${lastFrame}`,
396
+ );
397
+ }
398
+
399
+ // Retry while a render still holds the buffer lease.
400
+ // eslint-disable-next-line no-await-in-loop
401
+ const frame = await captureSettledFrame(setup);
402
+
403
+ polls++;
404
+
405
+ if (frame !== lastFrame) {
406
+ frameChanges++;
407
+ lastFrame = frame;
408
+ }
409
+
410
+ if (predicate()) return;
411
+
412
+ const now = Date.now();
413
+
414
+ if (now > deadline) {
415
+ const frozen = frameChanges <= 1 ? " - render never advanced (frozen)" : "";
416
+
417
+ throw new Error(
418
+ `waitForState ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
419
+ );
420
+ }
421
+
422
+ if (now - lastLog >= PROGRESS_LOG_MS) {
423
+ lastLog = now;
424
+ console.error(
425
+ `[waitForState] still waiting for ${label} - ${now - start}ms, ${polls} polls, ${frameChanges} frame changes`,
426
+ );
427
+ }
428
+
429
+ await yieldEventLoop();
430
+ }
431
+ }
432
+
433
+ /** Wait until the frame contains the needle (daemon round-trips included). */
434
+ export async function waitForText(setup: TestRendererSetup, needle: string): Promise<string> {
435
+ return waitForFramePredicate(
436
+ setup,
437
+ (frame) => frame.includes(needle),
438
+ `text ${JSON.stringify(needle)}`,
439
+ );
440
+ }
441
+
442
+ /** Wait until the frame no longer contains the needle. */
443
+ export async function waitForTextGone(setup: TestRendererSetup, needle: string): Promise<string> {
444
+ return waitForFramePredicate(
445
+ setup,
446
+ (frame) => !frame.includes(needle),
447
+ `text-gone ${JSON.stringify(needle)}`,
448
+ );
449
+ }