@cueloop/client 0.1.0-alpha.0 → 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 (345) 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 +41 -27
  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/App.autoclose.test.tsx +144 -0
  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 -80
  333. package/src/App.readonly.test.tsx +0 -148
  334. package/src/App.test.tsx +0 -217
  335. package/src/App.tsx +0 -0
  336. package/src/config.test.ts +0 -116
  337. package/src/config.ts +0 -113
  338. package/src/editor.ts +0 -34
  339. package/src/run.ts +0 -28
  340. package/src/serve.ts +0 -75
  341. package/src/theme.ts +0 -46
  342. package/src/view-diff.test.ts +0 -55
  343. package/src/view-diff.ts +0 -86
  344. package/src/view.test.ts +0 -157
  345. package/src/view.ts +0 -0
@@ -0,0 +1,55 @@
1
+ /**
2
+ * UI state for the share dialog, held in a zustand store rather than juggled
3
+ * across component state: which category is open (share externally / export),
4
+ * whether the link wizard is showing, and the wizard's draft (name, auth,
5
+ * allowlist). The links themselves live on the thread; this is only the
6
+ * ephemeral surface state.
7
+ */
8
+
9
+ import { createStore } from "zustand/vanilla";
10
+ import { useStore } from "zustand";
11
+
12
+ export type ShareCategory = "external" | "export";
13
+
14
+ export interface ShareWizardDraft {
15
+ editingId: string | null;
16
+ name: string;
17
+ requireAuth: boolean;
18
+ allowlist: string[];
19
+ }
20
+
21
+ export interface ShareDialogState {
22
+ category: ShareCategory;
23
+ wizard: ShareWizardDraft | null;
24
+ setCategory: (category: ShareCategory) => void;
25
+ openWizard: (draft: ShareWizardDraft) => void;
26
+ closeWizard: () => void;
27
+ setName: (name: string) => void;
28
+ setRequireAuth: (requireAuth: boolean) => void;
29
+ setAllowlist: (allowlist: string[]) => void;
30
+ reset: () => void;
31
+ }
32
+
33
+ /** A fresh wizard draft, prefilled with the thread name for a new link. */
34
+ export function newWizardDraft(threadName: string): ShareWizardDraft {
35
+ return { editingId: null, name: threadName, requireAuth: false, allowlist: [] };
36
+ }
37
+
38
+ export const shareDialogStore = createStore<ShareDialogState>((set) => ({
39
+ category: "external",
40
+ wizard: null,
41
+ setCategory: (category) => set({ category }),
42
+ openWizard: (wizard) => set({ wizard }),
43
+ closeWizard: () => set({ wizard: null }),
44
+ setName: (name) => set((state) => (state.wizard ? { wizard: { ...state.wizard, name } } : {})),
45
+ setRequireAuth: (requireAuth) =>
46
+ set((state) => (state.wizard ? { wizard: { ...state.wizard, requireAuth } } : {})),
47
+ setAllowlist: (allowlist) =>
48
+ set((state) => (state.wizard ? { wizard: { ...state.wizard, allowlist } } : {})),
49
+ reset: () => set({ category: "external", wizard: null }),
50
+ }));
51
+
52
+ /** Subscribe a component to the share dialog store. */
53
+ export function useShareDialog<T>(selector: (state: ShareDialogState) => T): T {
54
+ return useStore(shareDialogStore, selector);
55
+ }
@@ -0,0 +1,149 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { editInEditor, resolveEditor, resolveEditorCommand } from "./editor";
6
+
7
+ describe("resolveEditor", () => {
8
+ test("a clean environment falls back to nano so editing always works", () => {
9
+ // Assert
10
+ expect(resolveEditor(undefined, {})).toBe("nano");
11
+ });
12
+
13
+ test("the environment chain wins in order: CUELOOP_EDITOR, VISUAL, EDITOR", () => {
14
+ // Assert
15
+ expect(resolveEditor(undefined, { EDITOR: "vim", VISUAL: "code", CUELOOP_EDITOR: "hx" })).toBe(
16
+ "hx",
17
+ );
18
+ expect(resolveEditor(undefined, { EDITOR: "vim", VISUAL: "code" })).toBe("code");
19
+ expect(resolveEditor(undefined, { EDITOR: "vim" })).toBe("vim");
20
+ });
21
+
22
+ test("the [ui] editor config overrides the environment", () => {
23
+ // Assert
24
+ expect(resolveEditor("code --wait", { EDITOR: "vim" })).toBe("code --wait");
25
+ });
26
+ });
27
+
28
+ describe("resolveEditorCommand", () => {
29
+ test("a known GUI editor gets its wait flag appended and is trusted to wait", () => {
30
+ // Assert
31
+ expect(resolveEditorCommand("code")).toEqual({ argv: ["code", "--wait"], waits: true });
32
+ expect(resolveEditorCommand("subl")).toEqual({
33
+ argv: ["subl", "--new-window", "--wait"],
34
+ waits: true,
35
+ });
36
+ });
37
+
38
+ test("an existing wait flag is not duplicated", () => {
39
+ // Assert
40
+ expect(resolveEditorCommand("code --wait").argv).toEqual(["code", "--wait"]);
41
+ });
42
+
43
+ test("a terminal editor is left alone and trusted to hold the tty", () => {
44
+ // Assert
45
+ expect(resolveEditorCommand("vim")).toEqual({ argv: ["vim"], waits: true });
46
+ });
47
+
48
+ test("an unknown editor is neither flagged nor trusted to wait", () => {
49
+ // Assert
50
+ expect(resolveEditorCommand("my-editor")).toEqual({ argv: ["my-editor"], waits: false });
51
+ });
52
+ });
53
+
54
+ describe("editInEditor", () => {
55
+ let dir: string;
56
+
57
+ beforeEach(() => {
58
+ dir = mkdtempSync(join(tmpdir(), "cueloop-edit-test-"));
59
+ });
60
+ afterEach(() => {
61
+ rmSync(dir, { recursive: true, force: true });
62
+ });
63
+
64
+ /** A runnable editor stub with a chosen base name and body. */
65
+ function stubEditor(name: string, body: string): string {
66
+ const script = join(dir, name);
67
+
68
+ writeFileSync(script, `#!/bin/sh\n${body}\n`);
69
+ chmodSync(script, 0o755);
70
+
71
+ return script;
72
+ }
73
+
74
+ const instant = (): (() => number) => {
75
+ let value = 0;
76
+
77
+ return () => (value += 5);
78
+ };
79
+
80
+ test("an editor that changes the file reports the new content", () => {
81
+ // Act
82
+ const result = editInEditor("old", "plan.md", {
83
+ editor: stubEditor("writer.sh", `printf 'new' > "$1"`),
84
+ });
85
+
86
+ // Assert
87
+ expect(result).toEqual({ content: "new", changed: true });
88
+ });
89
+
90
+ test("a fast, unchanged return opens the gate; confirming re-reads the saved file", () => {
91
+ // Arrange
92
+ let askedLabel = "";
93
+
94
+ // Act
95
+ const result = editInEditor("keep", "plan.md", {
96
+ editor: stubEditor("gui-like.sh", "exit 0"),
97
+ now: instant(),
98
+ confirmSaved: (label, path) => {
99
+ askedLabel = label;
100
+ writeFileSync(path, "saved in the GUI");
101
+
102
+ return true;
103
+ },
104
+ });
105
+
106
+ // Assert
107
+ expect(askedLabel).toContain("gui-like.sh");
108
+ expect(result).toEqual({ content: "saved in the GUI", changed: true });
109
+ });
110
+
111
+ test("declining the gate keeps the original content", () => {
112
+ // Act
113
+ const result = editInEditor("keep", "plan.md", {
114
+ editor: stubEditor("gui-like.sh", "exit 0"),
115
+ now: instant(),
116
+ confirmSaved: () => false,
117
+ });
118
+
119
+ // Assert
120
+ expect(result).toEqual({ content: "keep", changed: false });
121
+ });
122
+
123
+ test("a terminal editor that makes no change never opens the gate", () => {
124
+ // Arrange
125
+ let asked = false;
126
+
127
+ // Act
128
+ const result = editInEditor("keep", "plan.md", {
129
+ editor: stubEditor("vi", "exit 0"),
130
+ now: instant(),
131
+ confirmSaved: () => {
132
+ asked = true;
133
+
134
+ return true;
135
+ },
136
+ });
137
+
138
+ // Assert
139
+ expect(asked).toBeFalse();
140
+ expect(result).toEqual({ content: "keep", changed: false });
141
+ });
142
+
143
+ test("a nonzero editor exit throws", () => {
144
+ // Assert
145
+ expect(() => editInEditor("x", "plan.md", { editor: stubEditor("fail.sh", "exit 3") })).toThrow(
146
+ "editor exited 3",
147
+ );
148
+ });
149
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * File-level edit mode = the editor hand-off: write the working copy to a temp
3
+ * file, run the reviewer's editor on it, read the result back. Editing must
4
+ * work with zero configuration, so the resolution chain ends in a terminal
5
+ * fallback that is always present. A GUI editor launched without a wait flag
6
+ * forks and returns instantly, losing the edit silently; known GUI editors get
7
+ * their wait flag applied, and any editor that still returns right away with an
8
+ * untouched file drops to a confirm gate rather than discarding the work.
9
+ */
10
+
11
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ export interface EditResult {
16
+ content: string;
17
+ changed: boolean;
18
+ }
19
+
20
+ /** Present on every POSIX system, so a clean shell can still edit. */
21
+ const DEFAULT_EDITOR = "nano";
22
+
23
+ /** GUI editors fork and return immediately unless given their wait flag. */
24
+ interface GuiWaitFlags {
25
+ [editor: string]: string[];
26
+ }
27
+
28
+ const GUI_WAIT_FLAGS: GuiWaitFlags = {
29
+ code: ["--wait"],
30
+ "code-insiders": ["--wait"],
31
+ codium: ["--wait"],
32
+ cursor: ["--wait"],
33
+ windsurf: ["--wait"],
34
+ subl: ["--new-window", "--wait"],
35
+ sublime_text: ["--new-window", "--wait"],
36
+ zed: ["--wait"],
37
+ bbedit: ["--wait"],
38
+ mate: ["--wait"],
39
+ gedit: ["--wait"],
40
+ kate: ["--block"],
41
+ idea: ["--wait"],
42
+ webstorm: ["--wait"],
43
+ };
44
+
45
+ /** Editors that hold the terminal themselves - never GUI, never a wait gate. */
46
+ const TERMINAL_EDITORS = new Set([
47
+ "vi",
48
+ "vim",
49
+ "nvim",
50
+ "nano",
51
+ "pico",
52
+ "emacs",
53
+ "emacsclient",
54
+ "micro",
55
+ "hx",
56
+ "helix",
57
+ "kak",
58
+ "ed",
59
+ "joe",
60
+ "mcedit",
61
+ ]);
62
+
63
+ export interface ResolvedEditor {
64
+ argv: string[];
65
+ waits: boolean;
66
+ }
67
+
68
+ /** The editor to run: [ui] editor, then the environment chain, then nano. */
69
+ export function resolveEditor(
70
+ configuredEditor: string | undefined,
71
+ env: Record<string, string | undefined> = process.env,
72
+ ): string {
73
+ const candidates = [configuredEditor, env.CUELOOP_EDITOR, env.VISUAL, env.EDITOR];
74
+
75
+ return candidates.find((candidate) => candidate && candidate.trim())?.trim() ?? DEFAULT_EDITOR;
76
+ }
77
+
78
+ function editorBaseName(command: string): string {
79
+ return (command.split(/[\\/]/).pop() ?? command).toLowerCase();
80
+ }
81
+
82
+ /** Split the editor string and apply a GUI wait flag when we know one. */
83
+ export function resolveEditorCommand(rawEditor: string): ResolvedEditor {
84
+ const parts = rawEditor.trim().split(/\s+/);
85
+ const base = editorBaseName(parts[0] ?? "");
86
+
87
+ if (TERMINAL_EDITORS.has(base)) return { argv: parts, waits: true };
88
+ const waitFlags = GUI_WAIT_FLAGS[base];
89
+
90
+ if (waitFlags) {
91
+ const alreadyWaits = parts.some(
92
+ (part) => part === "--wait" || part === "-w" || part === "--block",
93
+ );
94
+
95
+ return { argv: alreadyWaits ? parts : [...parts, ...waitFlags], waits: true };
96
+ }
97
+
98
+ return { argv: parts, waits: false };
99
+ }
100
+
101
+ /** A fast return with an untouched file means the editor probably did not wait. */
102
+ const NO_WAIT_THRESHOLD_MS = 1000;
103
+
104
+ function suspectsNoWait(resolved: ResolvedEditor, elapsedMs: number, unchanged: boolean): boolean {
105
+ if (resolved.waits) return false; // holds the terminal or has a wait flag - trust the exit
106
+ if (!unchanged) return false; // edits landed, so it waited after all
107
+
108
+ return elapsedMs < NO_WAIT_THRESHOLD_MS;
109
+ }
110
+
111
+ export interface EditHandOff {
112
+ editor?: string;
113
+ env?: Record<string, string | undefined>;
114
+ confirmSaved?: (editorLabel: string, path: string) => boolean;
115
+ now?: () => number;
116
+ }
117
+
118
+ /** Prompt on the released terminal during the suspend window. */
119
+ function promptSaved(editorLabel: string, path: string): boolean {
120
+ const answer = prompt(
121
+ `${editorLabel} returned immediately and the plan is unchanged - a GUI editor needs its wait flag. Save and close ${path}, then press Enter to load your edits (or type n to skip):`,
122
+ );
123
+
124
+ return answer !== null && answer.trim().toLowerCase() !== "n";
125
+ }
126
+
127
+ export function editInEditor(
128
+ content: string,
129
+ filename = "plan.md",
130
+ handOff: EditHandOff = {},
131
+ ): EditResult {
132
+ const env = handOff.env ?? process.env;
133
+ const now = handOff.now ?? Date.now;
134
+ const resolved = resolveEditorCommand(resolveEditor(handOff.editor, env));
135
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-edit-"));
136
+ const path = join(dir, filename);
137
+
138
+ writeFileSync(path, content);
139
+ try {
140
+ const startedAt = now();
141
+ const editorProcess = Bun.spawnSync([...resolved.argv, path], {
142
+ stdin: "inherit",
143
+ stdout: "inherit",
144
+ stderr: "inherit",
145
+ });
146
+
147
+ if (editorProcess.exitCode !== 0) throw new Error(`editor exited ${editorProcess.exitCode}`);
148
+ let next = readFileSync(path, "utf8");
149
+
150
+ if (suspectsNoWait(resolved, now() - startedAt, next === content)) {
151
+ const confirm = handOff.confirmSaved ?? promptSaved;
152
+
153
+ if (confirm(resolved.argv[0] ?? "the editor", path)) next = readFileSync(path, "utf8");
154
+ }
155
+
156
+ return { content: next, changed: next !== content };
157
+ } finally {
158
+ rmSync(dir, { recursive: true, force: true });
159
+ }
160
+ }
@@ -0,0 +1,58 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { resolveGithubIdentity } from "./github-identity";
6
+
7
+ const originalBinary = process.env.CUELOOP_GH;
8
+ const tempDirectories: string[] = [];
9
+
10
+ afterEach(() => {
11
+ if (originalBinary === undefined) delete process.env.CUELOOP_GH;
12
+ else process.env.CUELOOP_GH = originalBinary;
13
+ for (const directory of tempDirectories.splice(0)) {
14
+ rmSync(directory, { recursive: true, force: true });
15
+ }
16
+ });
17
+
18
+ function stubGithubCli(script: string): string {
19
+ const directory = mkdtempSync(join(tmpdir(), "gh-stub-"));
20
+
21
+ tempDirectories.push(directory);
22
+ const path = join(directory, "gh");
23
+
24
+ writeFileSync(path, `#!/bin/sh\n${script}\n`);
25
+ chmodSync(path, 0o755);
26
+
27
+ return path;
28
+ }
29
+
30
+ test("reads the GitHub login and name from gh", async () => {
31
+ process.env.CUELOOP_GH = stubGithubCli(`printf '{"login":"markus","name":"Markus M"}'`);
32
+
33
+ expect(await resolveGithubIdentity()).toEqual({ login: "markus", name: "Markus M" });
34
+ });
35
+
36
+ test("drops a null name to undefined", async () => {
37
+ process.env.CUELOOP_GH = stubGithubCli(`printf '{"login":"markus","name":null}'`);
38
+
39
+ expect(await resolveGithubIdentity()).toEqual({ login: "markus" });
40
+ });
41
+
42
+ test("falls back to null when gh exits non-zero (logged out)", async () => {
43
+ process.env.CUELOOP_GH = stubGithubCli(`exit 1`);
44
+
45
+ expect(await resolveGithubIdentity()).toBeNull();
46
+ });
47
+
48
+ test("falls back to null when gh is not installed", async () => {
49
+ process.env.CUELOOP_GH = join(tmpdir(), "cueloop-absent-gh-binary");
50
+
51
+ expect(await resolveGithubIdentity()).toBeNull();
52
+ });
53
+
54
+ test("falls back to null when gh prints an unexpected shape", async () => {
55
+ process.env.CUELOOP_GH = stubGithubCli(`printf 'not json'`);
56
+
57
+ expect(await resolveGithubIdentity()).toBeNull();
58
+ });
@@ -0,0 +1,41 @@
1
+ /** The signed-in GitHub identity from the local gh tool, or null when gh cannot answer. */
2
+
3
+ import * as v from "valibot";
4
+
5
+ export interface GithubIdentity {
6
+ login: string;
7
+ name?: string;
8
+ }
9
+
10
+ const GithubUserSchema = v.object({
11
+ login: v.string(),
12
+ name: v.nullish(v.string()),
13
+ });
14
+
15
+ /** The gh binary is injectable so tests stub it without a real GitHub login. */
16
+ function githubCliBinary(): string {
17
+ return process.env.CUELOOP_GH || "gh";
18
+ }
19
+
20
+ /** Read the authenticated GitHub login and name, or null when gh cannot answer. */
21
+ export async function resolveGithubIdentity(): Promise<GithubIdentity | null> {
22
+ try {
23
+ const githubProcess = Bun.spawn(
24
+ [githubCliBinary(), "api", "user", "--jq", "{login: .login, name: .name}"],
25
+ { stdout: "pipe", stderr: "ignore" },
26
+ );
27
+ const [output, exitCode] = await Promise.all([
28
+ new Response(githubProcess.stdout).text(),
29
+ githubProcess.exited,
30
+ ]);
31
+
32
+ if (exitCode !== 0) return null;
33
+ const parsed = v.safeParse(GithubUserSchema, JSON.parse(output));
34
+
35
+ if (!parsed.success) return null;
36
+
37
+ return { login: parsed.output.login, name: parsed.output.name ?? undefined };
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
@@ -0,0 +1,43 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { focusHerdrPane } from "./herdr";
6
+
7
+ let dir: string;
8
+
9
+ beforeEach(() => {
10
+ dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-"));
11
+ });
12
+ afterEach(() => {
13
+ rmSync(dir, { recursive: true, force: true });
14
+ });
15
+
16
+ describe("focusHerdrPane", () => {
17
+ test("resolves the pane's tab and focuses it", () => {
18
+ // Arrange
19
+ const log = join(dir, "calls.log");
20
+ const stub = join(dir, "herdr");
21
+
22
+ writeFileSync(
23
+ stub,
24
+ `#!/bin/sh
25
+ echo "$@" >> "${log}"
26
+ if [ "$1" = "pane" ]; then echo '{"result":{"pane":{"tab_id":"w1:t3"}}}'; fi
27
+ `,
28
+ );
29
+ Bun.spawnSync(["chmod", "+x", stub]);
30
+
31
+ // Assert
32
+ expect(focusHerdrPane(stub, "w1:p1")).toBe(true);
33
+ const calls = readFileSync(log, "utf8").trim().split("\n");
34
+
35
+ expect(calls[0]).toBe("pane get w1:p1");
36
+ expect(calls[1]).toBe("tab focus w1:t3");
37
+ });
38
+
39
+ test("a missing binary never throws", () => {
40
+ // Assert
41
+ expect(focusHerdrPane(join(dir, "nope"), "w1:p1")).toBe(false);
42
+ });
43
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * First-class herdr return: a review opened beside an agent hands focus back
3
+ * to the agent's pane when it closes, not leaving the reviewer on a dead tab.
4
+ * The env contract and the return-target rule live in @cueloop/schema; this
5
+ * module owns the one reviewer-side action that spawns the herdr CLI. Focus
6
+ * is best-effort - a failure never blocks closing the review.
7
+ */
8
+
9
+ import * as v from "valibot";
10
+
11
+ const HerdrPaneSchema = v.object({
12
+ result: v.optional(v.object({ pane: v.optional(v.object({ tab_id: v.optional(v.string()) })) })),
13
+ });
14
+
15
+ /** Focus the tab holding the target pane. Synchronous: runs just before exit. */
16
+ export function focusHerdrPane(binPath: string, paneId: string): boolean {
17
+ try {
18
+ const got = Bun.spawnSync([binPath, "pane", "get", paneId], {
19
+ stdout: "pipe",
20
+ stderr: "ignore",
21
+ });
22
+
23
+ if (got.exitCode !== 0) return false;
24
+ const parsed = v.safeParse(HerdrPaneSchema, JSON.parse(got.stdout.toString()));
25
+
26
+ if (!parsed.success) return false;
27
+ const tab = parsed.output.result?.pane?.tab_id;
28
+
29
+ if (!tab) return false;
30
+
31
+ return (
32
+ Bun.spawnSync([binPath, "tab", "focus", tab], { stdout: "ignore", stderr: "ignore" })
33
+ .exitCode === 0
34
+ );
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { OBSIDIAN_DEFAULTS } from "@cueloop/integration-obsidian";
3
+ import { loadBundledExporters } from "./integrations";
4
+ import type { IntegrationsConfig } from "../settings/config";
5
+
6
+ function integrations(overrides: Partial<typeof OBSIDIAN_DEFAULTS> = {}): IntegrationsConfig {
7
+ return { obsidian: { ...OBSIDIAN_DEFAULTS, ...overrides } };
8
+ }
9
+
10
+ describe("loadBundledExporters", () => {
11
+ test("composes the obsidian integration into one named exporter", async () => {
12
+ // Act
13
+ const exporters = await loadBundledExporters(integrations());
14
+
15
+ // Assert
16
+ expect(exporters.map((exporter) => exporter.name)).toEqual(["obsidian"]);
17
+ });
18
+
19
+ test("runsOn mirrors the configured export policy", async () => {
20
+ // Arrange
21
+ const onApprove = (await loadBundledExporters(integrations({ exportOn: "approved" })))[0]!;
22
+
23
+ // Assert
24
+ expect(onApprove.runsOn("approved")).toBeTrue();
25
+ expect(onApprove.runsOn("changes_requested")).toBeFalse();
26
+
27
+ // Arrange
28
+ const onMessage = (await loadBundledExporters(integrations({ exportOn: "message" })))[0]!;
29
+
30
+ // Assert
31
+ expect(onMessage.runsOn("approved")).toBeTrue();
32
+ expect(onMessage.runsOn("changes_requested")).toBeTrue();
33
+
34
+ // Arrange
35
+ const manual = (await loadBundledExporters(integrations({ exportOn: "manual" })))[0]!;
36
+
37
+ // Assert
38
+ expect(manual.runsOn("approved")).toBeFalse();
39
+ expect(manual.runsOn("changes_requested")).toBeFalse();
40
+ });
41
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Bundled integration wiring: composes the configured integrations into
3
+ * generic exporters plus their run policy, so the session controller depends
4
+ * only on the extension seam, never on an integration's own config type. This
5
+ * and config.ts are the only client modules that name a concrete integration.
6
+ */
7
+
8
+ import { Registry, type Exporter } from "@cueloop/extension-api";
9
+ import { createObsidianExtension, shouldExport } from "@cueloop/integration-obsidian";
10
+ import type { MessageOutcome } from "@cueloop/schema";
11
+ import type { IntegrationsConfig } from "../settings/config";
12
+
13
+ /** An exporter plus the policy deciding which messages trigger it. */
14
+ export interface BundledExporter {
15
+ name: string;
16
+ run: Exporter;
17
+ runsOn(message: MessageOutcome): boolean;
18
+ }
19
+
20
+ /** Load the bundled integrations from config as generic exporters. */
21
+ export async function loadBundledExporters(
22
+ integrations: IntegrationsConfig,
23
+ ): Promise<BundledExporter[]> {
24
+ const registry = new Registry();
25
+ const obsidian = integrations.obsidian;
26
+ const record = await registry.load("obsidian", createObsidianExtension(obsidian));
27
+ const run = record.exporters.get("obsidian");
28
+
29
+ if (!run) return [];
30
+
31
+ return [{ name: "obsidian", run, runsOn: (message) => shouldExport(obsidian.exportOn, message) }];
32
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { imagePlaceholder, looksLikeBinaryPaste } from "./pasted-image";
3
+
4
+ function bytesOf(...values: number[]): Uint8Array {
5
+ return Uint8Array.from(values);
6
+ }
7
+
8
+ describe("looksLikeBinaryPaste", () => {
9
+ test("plain text pastes stay text", () => {
10
+ expect(looksLikeBinaryPaste(new TextEncoder().encode("just a normal comment"))).toBe(false);
11
+ });
12
+
13
+ test("multi-line text with tabs and newlines stays text", () => {
14
+ expect(
15
+ looksLikeBinaryPaste(new TextEncoder().encode("line one\n\tline two\r\nline three")),
16
+ ).toBe(false);
17
+ });
18
+
19
+ test("unicode text stays text", () => {
20
+ expect(looksLikeBinaryPaste(new TextEncoder().encode("café — naïve — 日本語 — 🎉"))).toBe(
21
+ false,
22
+ );
23
+ });
24
+
25
+ test("a PNG signature is binary", () => {
26
+ expect(looksLikeBinaryPaste(bytesOf(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))).toBe(
27
+ true,
28
+ );
29
+ });
30
+
31
+ test("a JPEG signature is binary", () => {
32
+ expect(looksLikeBinaryPaste(bytesOf(0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10))).toBe(true);
33
+ });
34
+
35
+ test("a GIF signature is binary", () => {
36
+ expect(looksLikeBinaryPaste(new TextEncoder().encode("GIF89a\x01\x02"))).toBe(true);
37
+ });
38
+
39
+ test("a WEBP signature is binary", () => {
40
+ expect(
41
+ looksLikeBinaryPaste(bytesOf(0x52, 0x49, 0x46, 0x46, 1, 2, 3, 4, 0x57, 0x45, 0x42, 0x50)),
42
+ ).toBe(true);
43
+ });
44
+
45
+ test("a NUL byte marks binary", () => {
46
+ expect(looksLikeBinaryPaste(bytesOf(0x68, 0x69, 0x00, 0x74, 0x68, 0x65, 0x72, 0x65))).toBe(
47
+ true,
48
+ );
49
+ });
50
+
51
+ test("a burst of control bytes marks binary", () => {
52
+ expect(looksLikeBinaryPaste(bytesOf(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08))).toBe(
53
+ true,
54
+ );
55
+ });
56
+
57
+ test("an empty paste is not binary", () => {
58
+ expect(looksLikeBinaryPaste(new Uint8Array())).toBe(false);
59
+ });
60
+ });
61
+
62
+ describe("imagePlaceholder", () => {
63
+ test("numbers each pasted image", () => {
64
+ expect(imagePlaceholder(1)).toBe("[Image #1]");
65
+ expect(imagePlaceholder(2)).toBe("[Image #2]");
66
+ });
67
+ });