@cueloop/client 0.1.0-alpha.10 → 0.1.0-alpha.100

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