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

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 +1152 -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 +754 -0
  196. package/src/settings/config.ts +718 -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 +424 -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,446 @@
1
+ /**
2
+ * The keyboard grammar as a pure reducer: view state in, intents out.
3
+ * App builds a KeyState per key event, calls reduceKey, and dispatches the
4
+ * intents to controller primitives and view-state setters - no branch bodies live
5
+ * in the key handler. Diff and plan reviews share one path for annotation
6
+ * navigation, deletion, and submit; the read-only rule is one gate here.
7
+ */
8
+
9
+ import { actionFor } from "../settings/config";
10
+
11
+ export type Intent =
12
+ | { type: "exit" }
13
+ | { type: "status"; message: string }
14
+ | { type: "move"; to: "down" | "up" | "top" | "bottom" }
15
+ | { type: "inboxMove"; to: "down" | "up" }
16
+ | { type: "openSession" }
17
+ | { type: "requestDeleteSession" }
18
+ | { type: "openRename" }
19
+ | { type: "confirmDialog" }
20
+ | { type: "startSpan" }
21
+ | { type: "spanKey"; name: string }
22
+ | { type: "spanCut" }
23
+ | { type: "openSpanActions" }
24
+ | { type: "moveSpanAction"; direction: -1 | 1 }
25
+ | { type: "pickSpanAction"; index?: number }
26
+ | { type: "closeSpanActions" }
27
+ | { type: "openCompose"; kind: "comment"; from: "cursor" | "span" }
28
+ | { type: "openSubmit" }
29
+ | { type: "share" }
30
+ | { type: "cut" }
31
+ | { type: "edit" }
32
+ | { type: "editCard" }
33
+ | { type: "rejectHunk" }
34
+ | { type: "rejectChange" }
35
+ | { type: "restoreCuration" }
36
+ | { type: "foldFile" }
37
+ | { type: "unfoldFile" }
38
+ | { type: "toggleDiffView" }
39
+ | { type: "nextAnnotation" }
40
+ | { type: "prevAnnotation" }
41
+ | { type: "walkStart" }
42
+ | { type: "walkForward" }
43
+ | { type: "walkBack" }
44
+ | { type: "walkLeave" }
45
+ | { type: "removeAnnotation" }
46
+ | { type: "deselect" }
47
+ | { type: "closeOverlay" }
48
+ | { type: "saveCompose" }
49
+ | { type: "submitMessage" }
50
+ | { type: "cycleMessage"; direction: -1 | 1 }
51
+ | { type: "finishReview" }
52
+ | { type: "optInAutoClose" }
53
+ | { type: "dismissCompletion" }
54
+ | { type: "toggleTree" }
55
+ | { type: "treeMove"; direction: -1 | 1 }
56
+ | { type: "treeGo" }
57
+ | { type: "treeBranch" }
58
+ | { type: "treeLabel" }
59
+ | { type: "treeFork" }
60
+ | { type: "treeForkShare" };
61
+
62
+ export interface KeyInput {
63
+ name: string;
64
+ shift: boolean;
65
+ meta?: boolean;
66
+ }
67
+
68
+ export interface KeyState {
69
+ keys: Record<string, string[]>;
70
+ readOnly: boolean;
71
+ canEditPlan?: boolean;
72
+ canSubmitMessage?: boolean;
73
+ canShare?: boolean;
74
+ overlay:
75
+ | "none"
76
+ | "walk"
77
+ | "compose"
78
+ | "submit"
79
+ | "confirm"
80
+ | "prompt"
81
+ | "spanActions"
82
+ | "completion-prompt"
83
+ | "completion-counting";
84
+ view: "inbox" | "plan" | "diff";
85
+ spanMode: boolean;
86
+ walkAtEnd: boolean;
87
+ resolved: boolean;
88
+ hasInboxItems: boolean;
89
+ annotationCount: number;
90
+ hasFocusedAnnotation: boolean;
91
+ cursorAnnotatable: boolean;
92
+ }
93
+
94
+ /** Primitives that write session state; an observer never reaches their handlers. */
95
+ const MUTATING_ACTIONS = new Set([
96
+ "comment",
97
+ "cut",
98
+ "edit",
99
+ "delete_annotation",
100
+ "submit",
101
+ "walk",
102
+ "share",
103
+ "reject_hunk",
104
+ "restore_curation",
105
+ ]);
106
+
107
+ const SPAN_KEYS = new Set(["l", "h", "w", "b", "$", "0"]);
108
+
109
+ function status(message: string): Intent[] {
110
+ return [{ type: "status", message }];
111
+ }
112
+
113
+ /**
114
+ * Reduce one key event to intents. `resolvedAction` is the binding-layer
115
+ * resolution (key-bindings.ts over @opentui/keymap) when the caller has one;
116
+ * without it the reducer falls back to the plain reverse lookup, so the
117
+ * grammar stays testable as a pure function.
118
+ */
119
+ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: string): Intent[] {
120
+ const name = key.name;
121
+ const overlayGrammar = overlayGrammars[state.overlay];
122
+
123
+ if (overlayGrammar) return overlayGrammar(state, key);
124
+
125
+ const action = resolvedAction ?? actionFor(state.keys, name, key.shift);
126
+
127
+ if (action === "quit") return [{ type: "exit" }];
128
+
129
+ // the ONE read-only rule: any mutating attempt answers instead of acting
130
+ // (span-mode c, x, and a are hardwired keys, so they gate by name as well)
131
+ const mutating =
132
+ MUTATING_ACTIONS.has(action ?? "") ||
133
+ (state.spanMode && (name === "c" || name === "x" || name === "a"));
134
+
135
+ if (state.readOnly && mutating) return status("observer - read-only");
136
+
137
+ // share is a session-level primitive: it works from any view, owner only
138
+ if (action === "share") {
139
+ if (state.canShare === false) return status("only the plan owner can share");
140
+
141
+ return [{ type: "share" }];
142
+ }
143
+
144
+ if (state.view === "inbox") return inboxGrammar(state, name);
145
+
146
+ // span mode owns its single-letter keys (b slides the span back)
147
+ if (state.spanMode) return spanGrammar(state, name);
148
+
149
+ if (state.view === "diff") return diffGrammar(state, action);
150
+
151
+ return planGrammar(state, action, name);
152
+ }
153
+
154
+ type OverlayGrammar = (state: KeyState, key: KeyInput) => Intent[];
155
+
156
+ const overlayGrammars: Partial<Record<KeyState["overlay"], OverlayGrammar>> = {
157
+ compose: composeOverlayGrammar,
158
+ submit: submitOverlayGrammar,
159
+ confirm: confirmOverlayGrammar,
160
+ prompt: promptOverlayGrammar,
161
+ spanActions: spanActionsOverlayGrammar,
162
+ "completion-prompt": completionOverlayGrammar,
163
+ "completion-counting": completionOverlayGrammar,
164
+ walk: walkOverlayGrammar,
165
+ };
166
+
167
+ function composeOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
168
+ const name = key.name;
169
+
170
+ if (name === "escape") return [{ type: "closeOverlay" }];
171
+
172
+ if (name === "return" || name === "enter") {
173
+ // In the composer, ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline - the
174
+ // focused textarea owns that; only a bare ⏎ saves. The submit overlay
175
+ // keeps its plain ⏎ submit.
176
+ if (key.shift || key.meta) return [];
177
+
178
+ return [{ type: "saveCompose" }];
179
+ }
180
+
181
+ return [];
182
+ }
183
+
184
+ function submitOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
185
+ const name = key.name;
186
+
187
+ if (name === "escape") return [{ type: "closeOverlay" }];
188
+
189
+ if (name === "left" || name === "right") {
190
+ return [{ type: "cycleMessage", direction: name === "left" ? -1 : 1 }];
191
+ }
192
+
193
+ return [];
194
+ }
195
+
196
+ function confirmOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
197
+ const name = key.name;
198
+
199
+ if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
200
+
201
+ if (name === "escape") return [{ type: "closeOverlay" }];
202
+
203
+ return [];
204
+ }
205
+
206
+ function promptOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
207
+ const name = key.name;
208
+
209
+ if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
210
+
211
+ if (name === "escape") return [{ type: "closeOverlay" }];
212
+
213
+ return [];
214
+ }
215
+
216
+ function spanActionsOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
217
+ const name = key.name;
218
+
219
+ if (name === "j" || name === "down") return [{ type: "moveSpanAction", direction: 1 }];
220
+
221
+ if (name === "k" || name === "up") return [{ type: "moveSpanAction", direction: -1 }];
222
+
223
+ if (name === "return" || name === "enter") return [{ type: "pickSpanAction" }];
224
+
225
+ if (name === "escape") return [{ type: "closeSpanActions" }];
226
+
227
+ return [];
228
+ }
229
+
230
+ function completionOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
231
+ const name = key.name;
232
+
233
+ if (name === "return" || name === "enter" || name === "q") return [{ type: "finishReview" }];
234
+
235
+ if (name === "a") return [{ type: "optInAutoClose" }];
236
+
237
+ if (name === "escape") return [{ type: "dismissCompletion" }];
238
+
239
+ return [];
240
+ }
241
+
242
+ function walkOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
243
+ const name = key.name;
244
+
245
+ if (name === "]") return [{ type: "walkForward" }];
246
+
247
+ if (name === "[") return [{ type: "walkBack" }];
248
+
249
+ if (name === "escape") return [{ type: "walkLeave" }];
250
+
251
+ if ((name === "return" || name === "enter") && state.walkAtEnd) {
252
+ return [{ type: "walkLeave" }, { type: "openSubmit" }];
253
+ }
254
+
255
+ if (name === "q") return [{ type: "exit" }];
256
+
257
+ return [];
258
+ }
259
+
260
+ function inboxGrammar(state: KeyState, name: string): Intent[] {
261
+ if (!state.hasInboxItems) return [];
262
+
263
+ if (name === "j" || name === "down") return [{ type: "inboxMove", to: "down" }];
264
+
265
+ if (name === "k" || name === "up") return [{ type: "inboxMove", to: "up" }];
266
+
267
+ if (name === "return" || name === "enter") return [{ type: "openSession" }];
268
+
269
+ if (name === "d") return [{ type: "requestDeleteSession" }];
270
+
271
+ return [];
272
+ }
273
+
274
+ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
275
+ const navigation = navigationIntent(action);
276
+
277
+ if (navigation) return navigation;
278
+
279
+ if (action === "walk") {
280
+ // marking viewed writes the session record, so a resolved review answers
281
+ if (state.resolved) return status("review submitted - read-only");
282
+
283
+ return [{ type: "walkStart" }];
284
+ }
285
+
286
+ if (action === "comment") {
287
+ if (state.resolved) return status("review submitted - read-only");
288
+
289
+ if (!state.cursorAnnotatable) return status("move to a code line to comment");
290
+
291
+ return [{ type: "openCompose", kind: "comment", from: "cursor" }];
292
+ }
293
+
294
+ // cut rejects the change under the cursor, reject_hunk the whole hunk; both
295
+ // write the working copy, so they gate on owner like a plan edit.
296
+ if (action === "cut" || action === "reject_hunk") {
297
+ if (state.resolved) return status("review submitted - read-only");
298
+
299
+ if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
300
+
301
+ return [{ type: action === "reject_hunk" ? "rejectHunk" : "rejectChange" }];
302
+ }
303
+
304
+ const viewToggle = viewToggleIntent(action);
305
+
306
+ if (viewToggle) return viewToggle;
307
+
308
+ // restore un-does a curated-out rejection from the rail; same owner gate as reject
309
+ if (action === "restore_curation") {
310
+ if (state.resolved) return status("review submitted - read-only");
311
+
312
+ if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
313
+
314
+ return [{ type: "restoreCuration" }];
315
+ }
316
+
317
+ const shared = annotationCluster(state, action);
318
+
319
+ if (shared) return shared;
320
+
321
+ if (action === "span" || action === "edit") {
322
+ return status("plan-only primitive - diff review uses c on a line");
323
+ }
324
+
325
+ return [];
326
+ }
327
+
328
+ function spanGrammar(state: KeyState, name: string): Intent[] {
329
+ if (name === "escape") return [{ type: "closeOverlay" }];
330
+
331
+ if (SPAN_KEYS.has(name)) return [{ type: "spanKey", name }];
332
+
333
+ // c comments, x cuts, a opens quick-actions - all mutate, so a resolved
334
+ // review is read-only, like the plan grammar
335
+ if (name === "c" || name === "x" || name === "a") {
336
+ if (state.resolved) return status("review submitted - read-only");
337
+
338
+ if (name === "c") return [{ type: "openCompose", kind: "comment", from: "span" }];
339
+
340
+ if (name === "a") return [{ type: "openSpanActions" }];
341
+
342
+ if (state.canEditPlan === false) return [];
343
+
344
+ return [{ type: "spanCut" }];
345
+ }
346
+
347
+ return [];
348
+ }
349
+
350
+ function planGrammar(state: KeyState, action: string | undefined, name: string): Intent[] {
351
+ const navigation = navigationIntent(action);
352
+
353
+ if (navigation) return navigation;
354
+
355
+ if (action === "walk") return status("the guided walk is a diff-review mode");
356
+
357
+ if (name === "escape") return [{ type: "deselect" }];
358
+
359
+ if (action === "span") return state.cursorAnnotatable ? [{ type: "startSpan" }] : [];
360
+
361
+ if (action === "comment") {
362
+ if (state.resolved) return status("review submitted - read-only");
363
+
364
+ if (!state.cursorAnnotatable) return status("text is cut - restore it first");
365
+
366
+ return [{ type: "openCompose", kind: "comment", from: "cursor" }];
367
+ }
368
+
369
+ // restore un-does a rail removal (a cut block); a plan edit, so owner-only
370
+ if (action === "restore_curation") {
371
+ if (state.resolved) return status("review submitted - read-only");
372
+
373
+ if (state.canEditPlan === false) return [];
374
+
375
+ return [{ type: "restoreCuration" }];
376
+ }
377
+
378
+ if (action === "cut" || action === "edit") {
379
+ if (state.resolved) return status("review submitted - read-only");
380
+
381
+ // the document selects, the rail edits: with a card selected, Cut deletes
382
+ // the annotation and edit rewrites the card body in place
383
+ if (state.hasFocusedAnnotation)
384
+ return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
385
+
386
+ // editing the plan itself is the owner's primitive; a share viewer only annotates,
387
+ // and has no edit affordance, so the key is silent rather than a nag
388
+ if (state.canEditPlan === false) return [];
389
+
390
+ return [{ type: action }];
391
+ }
392
+
393
+ return annotationCluster(state, action) ?? [];
394
+ }
395
+
396
+ /** Cursor movement is the same intent everywhere; views clamp their own bounds. */
397
+ function navigationIntent(action: string | undefined): Intent[] | null {
398
+ if (action === "down" || action === "up" || action === "top" || action === "bottom") {
399
+ return [{ type: "move", to: action }];
400
+ }
401
+
402
+ return null;
403
+ }
404
+
405
+ /** Ungated view toggles in the diff: right folds a file to its band, left unfolds it, s flips
406
+ * split/stacked (split lays out only when the Changes pane is wide/zoomed). */
407
+ function viewToggleIntent(action: string | undefined): Intent[] | null {
408
+ if (action === "collapse_file") return [{ type: "foldFile" }];
409
+
410
+ if (action === "expand_file") return [{ type: "unfoldFile" }];
411
+
412
+ if (action === "split_diff") return [{ type: "toggleDiffView" }];
413
+
414
+ return null;
415
+ }
416
+
417
+ /** Annotation navigation, deletion, and submit: one path for plan and diff. */
418
+ function annotationCluster(state: KeyState, action: string | undefined): Intent[] | null {
419
+ if (action === "next_annotation" || action === "prev_annotation") {
420
+ if (!state.annotationCount) return status("no annotations");
421
+
422
+ return [{ type: action === "next_annotation" ? "nextAnnotation" : "prevAnnotation" }];
423
+ }
424
+
425
+ if (action === "delete_annotation") {
426
+ if (state.resolved || !state.hasFocusedAnnotation) return [];
427
+
428
+ return [{ type: "removeAnnotation" }];
429
+ }
430
+
431
+ if (action === "rename") {
432
+ if (!state.hasFocusedAnnotation) return status("select a collaborator's note to rename them");
433
+
434
+ return [{ type: "openRename" }];
435
+ }
436
+
437
+ if (action === "submit") {
438
+ // a collaborator's notes union back as they go; there is no message to submit
439
+ if (state.canSubmitMessage === false)
440
+ return status("shared view - your notes save as you go; q to leave");
441
+
442
+ return state.resolved ? [] : [{ type: "openSubmit" }];
443
+ }
444
+
445
+ return null;
446
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { loadSkills } from "./skills";
6
+ import { mergeSlashItems, type SlashItem } from "./slash-palette";
7
+
8
+ function skillDir(): string {
9
+ const root = mkdtempSync(join(tmpdir(), "cueloop-skills-"));
10
+
11
+ mkdirSync(join(root, "typescript-magician"));
12
+ writeFileSync(
13
+ join(root, "typescript-magician", "SKILL.md"),
14
+ "---\nname: typescript-magician\ndescription: Design complex generic types.\n---\n\nbody\n",
15
+ );
16
+ mkdirSync(join(root, "no-frontmatter"));
17
+ writeFileSync(join(root, "no-frontmatter", "SKILL.md"), "just prose, no frontmatter\n");
18
+
19
+ return root;
20
+ }
21
+
22
+ describe("loadSkills", () => {
23
+ test("reads name and description from each SKILL.md frontmatter", () => {
24
+ const root = skillDir();
25
+
26
+ try {
27
+ const skills = loadSkills(root);
28
+ const magician = skills.find((skill) => skill.name === "typescript-magician");
29
+
30
+ expect(magician?.description).toBe("Design complex generic types.");
31
+ // a directory whose SKILL.md has no frontmatter is skipped, not crashed on
32
+ expect(skills.some((skill) => skill.name === "no-frontmatter")).toBe(false);
33
+ } finally {
34
+ rmSync(root, { recursive: true, force: true });
35
+ }
36
+ });
37
+
38
+ test("a missing skills directory yields no skills", () => {
39
+ expect(loadSkills(join(tmpdir(), "cueloop-does-not-exist-xyz"))).toEqual([]);
40
+ });
41
+
42
+ test("CRLF delimiters parse, and a quoted name is unquoted", () => {
43
+ const root = mkdtempSync(join(tmpdir(), "cueloop-skills-crlf-"));
44
+
45
+ try {
46
+ mkdirSync(join(root, "review"));
47
+ writeFileSync(
48
+ join(root, "review", "SKILL.md"),
49
+ '---\r\nname: "review"\r\ndescription: Review the change.\r\n---\r\n',
50
+ );
51
+ const skills = loadSkills(root);
52
+
53
+ // no stray quotes and no CRLF-broken frontmatter
54
+ expect(skills.map((skill) => skill.name)).toEqual(["review"]);
55
+ } finally {
56
+ rmSync(root, { recursive: true, force: true });
57
+ }
58
+ });
59
+ });
60
+
61
+ describe("mergeSlashItems", () => {
62
+ test("a quick action wins a name collision with a skill", () => {
63
+ const actions: SlashItem[] = [{ name: "lgtm", description: "action", body: "LGTM" }];
64
+ const skills: SlashItem[] = [
65
+ { name: "lgtm", description: "skill", body: "" },
66
+ { name: "vitest-patterns", description: "skill", body: "" },
67
+ ];
68
+ const merged = mergeSlashItems(actions, skills);
69
+
70
+ expect(merged.filter((item) => item.name === "lgtm")).toHaveLength(1);
71
+ expect(merged.find((item) => item.name === "lgtm")?.description).toBe("action");
72
+ expect(merged.some((item) => item.name === "vitest-patterns")).toBe(true);
73
+ });
74
+ });
@@ -0,0 +1,77 @@
1
+ import { createContext } from "react";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import * as v from "valibot";
6
+ import type { SlashItem } from "./slash-palette";
7
+
8
+ /** The default user-level skill store, harness-agnostic; a config path overrides it. */
9
+ export function defaultSkillsDir(): string {
10
+ return join(homedir(), ".agents", "skills");
11
+ }
12
+
13
+ const SkillNameSchema = v.pipe(v.string(), v.trim(), v.minLength(1));
14
+
15
+ /** One `key: value` line from the frontmatter, unquoted; undefined when the key is absent. */
16
+ function frontmatterValue(frontmatter: string, key: string): string | undefined {
17
+ const match = new RegExp(`^${key}:\\s*(.+)$`, "m").exec(frontmatter);
18
+
19
+ return match
20
+ ? match[1]!
21
+ .trim()
22
+ .replace(/^["']|["']$/g, "")
23
+ .trim()
24
+ : undefined;
25
+ }
26
+
27
+ /** User skills as palette items: each `<dir>/SKILL.md` whose frontmatter carries a name and description. */
28
+ export function loadSkills(dir: string): SlashItem[] {
29
+ let entries;
30
+
31
+ try {
32
+ entries = readdirSync(dir, { withFileTypes: true });
33
+ } catch {
34
+ return [];
35
+ }
36
+ const items: SlashItem[] = [];
37
+
38
+ for (const entry of entries) {
39
+ if (!entry.isDirectory()) continue;
40
+
41
+ let text;
42
+
43
+ try {
44
+ text = readFileSync(join(dir, entry.name, "SKILL.md"), "utf8");
45
+ } catch {
46
+ continue;
47
+ }
48
+ const frontmatter = /^---\n([\s\S]*?)\n---/.exec(text.replace(/\r\n/g, "\n"));
49
+
50
+ if (!frontmatter) continue;
51
+
52
+ const name = v.safeParse(
53
+ SkillNameSchema,
54
+ frontmatterValue(frontmatter[1]!, "name") ?? entry.name,
55
+ );
56
+
57
+ if (!name.success) continue;
58
+
59
+ items.push({
60
+ name: name.output,
61
+ description: frontmatterValue(frontmatter[1]!, "description") ?? "",
62
+ body: "",
63
+ });
64
+ }
65
+
66
+ return items;
67
+ }
68
+
69
+ /** Skills reach the composer palette through context, so no surface needs a new prop to list them. */
70
+ export const SlashSkillsContext = createContext<SlashItem[]>([]);
71
+
72
+ /**
73
+ * The action and skill names every composer paints as "/name" references. Held in
74
+ * context so each surface - the plan thread, the diff sheet, the prototype - reads
75
+ * the same palette without threading a prop.
76
+ */
77
+ export const PaletteNamesContext = createContext<ReadonlySet<string>>(new Set());
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ activeSlashToken,
4
+ insertSlashItem,
5
+ isStandaloneSlashQuery,
6
+ scoreMatch,
7
+ slashFilter,
8
+ type SlashItem,
9
+ } from "./slash-palette";
10
+
11
+ function item(name: string): SlashItem {
12
+ return { name, description: name, body: name };
13
+ }
14
+
15
+ const ITEMS: SlashItem[] = [
16
+ item("zoom-out-research-in-depth"),
17
+ item("restate-simplified"),
18
+ item("out-of-scope"),
19
+ item("lgtm"),
20
+ ];
21
+
22
+ describe("scoreMatch", () => {
23
+ test("an exact name scores highest, then a prefix, then a subsequence", () => {
24
+ const exact = scoreMatch("lgtm", "lgtm")!;
25
+ const prefix = scoreMatch("lgtm-please", "lgtm")!;
26
+ const subsequence = scoreMatch("looking-good-to-me", "lgtm")!;
27
+
28
+ expect(exact).toBeGreaterThan(prefix);
29
+ expect(prefix).toBeGreaterThan(subsequence);
30
+ });
31
+
32
+ test("a query whose characters are not all present, in order, does not match", () => {
33
+ expect(scoreMatch("restate", "zzz")).toBeNull();
34
+ expect(scoreMatch("abc", "acb")).toBeNull();
35
+ });
36
+
37
+ test("an empty query matches everything", () => {
38
+ expect(scoreMatch("anything", "")).toBe(0);
39
+ });
40
+ });
41
+
42
+ describe("slashFilter", () => {
43
+ test("ranks a prefix match above a mere subsequence match", () => {
44
+ const ranked = slashFilter(ITEMS, "out");
45
+
46
+ expect(ranked[0]!.name).toBe("out-of-scope");
47
+ expect(ranked.map((entry) => entry.name)).toContain("zoom-out-research-in-depth");
48
+ });
49
+
50
+ test("drops items that do not match and returns all on an empty query", () => {
51
+ expect(slashFilter(ITEMS, "zzzz")).toEqual([]);
52
+ expect(slashFilter(ITEMS, "")).toHaveLength(ITEMS.length);
53
+ });
54
+ });
55
+
56
+ describe("activeSlashToken", () => {
57
+ const atEnd = (text: string) => activeSlashToken(text, text.length);
58
+
59
+ test("finds the caret's /word at the start or after a space, so each new / reopens the palette", () => {
60
+ expect(atEnd("/vitest-patt")).toBe("/vitest-patt");
61
+ expect(atEnd("use /vitest-patt")).toBe("/vitest-patt");
62
+ expect(atEnd("/zoom-out hello world /")).toBe("/");
63
+ // an underscore is a valid skill-name character, so the token spans it
64
+ expect(atEnd("run /my_skill")).toBe("/my_skill");
65
+ });
66
+
67
+ test("closes on the space that ends the token, and ignores prose", () => {
68
+ expect(atEnd("/zoom-out ")).toBeNull();
69
+ expect(atEnd("just some prose")).toBeNull();
70
+ });
71
+
72
+ test("tracks the caret, not the string end: a slash mid-draft is the active token", () => {
73
+ // caret sits right after "/imp" in "/imp done", ignoring the trailing text
74
+ expect(activeSlashToken("/imp done", 4)).toBe("/imp");
75
+ // caret inside plain prose, even with a later slash token, has no active token
76
+ expect(activeSlashToken("plain text /later", 5)).toBeNull();
77
+ });
78
+ });
79
+
80
+ describe("isStandaloneSlashQuery", () => {
81
+ test("true only when the whole draft is a bare /query, so prose ending in /name still saves", () => {
82
+ expect(isStandaloneSlashQuery("/lgtm")).toBe(true);
83
+ expect(isStandaloneSlashQuery(" /lgtm ")).toBe(true);
84
+ expect(isStandaloneSlashQuery("please inspect /tmp")).toBe(false);
85
+ expect(isStandaloneSlashQuery("just prose")).toBe(false);
86
+ });
87
+ });
88
+
89
+ describe("insertSlashItem", () => {
90
+ test("replaces the active slash token with the reference and lands the caret after it", () => {
91
+ const result = insertSlashItem("look at /rev", 12, "review");
92
+
93
+ expect(result.text).toBe("look at /review ");
94
+ expect(result.caret).toBe("look at /review ".length);
95
+ });
96
+
97
+ test("keeps text after the caret intact", () => {
98
+ const result = insertSlashItem("/pl tail", 3, "plan");
99
+
100
+ expect(result.text).toBe("/plan tail");
101
+ expect(result.caret).toBe("/plan ".length);
102
+ });
103
+ });