@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,754 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ DEFAULT_KEYS,
7
+ DEFAULT_QUICK_ACTIONS,
8
+ actionFor,
9
+ loadConfig,
10
+ persistAuthorName,
11
+ persistActions,
12
+ persistLayout,
13
+ persistPins,
14
+ persistReviewSkill,
15
+ persistThreadModelDefault,
16
+ persistReviewWorkspace,
17
+ persistTheme,
18
+ quickActionBody,
19
+ resolveQuickAction,
20
+ } from "./config";
21
+ import { DARK } from "../appearance/theme";
22
+ import { themeForName } from "../appearance/theme-presets";
23
+
24
+ describe("loadConfig", () => {
25
+ test("review defaults use the bundled skill in an isolated worktree", () => {
26
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
27
+
28
+ expect(config.review).toEqual({ skill: "code-review", workspace: "worktree" });
29
+ });
30
+
31
+ test("review settings load and persist without changing other tables", () => {
32
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-review-config-"));
33
+ const path = join(dir, "config.toml");
34
+
35
+ writeFileSync(path, '[ui]\ntheme = "cueloop"\n\n[review]\nskill = "my-review"\n');
36
+ try {
37
+ persistReviewSkill("code-review", path);
38
+ persistReviewWorkspace("current", path);
39
+
40
+ expect(loadConfig({ userConfigPath: path }).review).toEqual({
41
+ skill: "code-review",
42
+ workspace: "current",
43
+ });
44
+ expect(readFileSync(path, "utf8")).toContain('[ui]\ntheme = "cueloop"');
45
+ } finally {
46
+ rmSync(dir, { recursive: true, force: true });
47
+ }
48
+ });
49
+
50
+ test("review settings update a table whose header has a comment", () => {
51
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-review-commented-config-"));
52
+ const path = join(dir, "config.toml");
53
+
54
+ writeFileSync(path, '[review] # preferred settings\nskill = "my-review"\n');
55
+ try {
56
+ persistReviewSkill("code-review", path);
57
+
58
+ const text = readFileSync(path, "utf8");
59
+
60
+ expect(loadConfig({ userConfigPath: path }).review.skill).toBe("code-review");
61
+ expect(text.match(/^\[review\]/gm)).toHaveLength(1);
62
+ expect(text).toContain("[review] # preferred settings");
63
+ } finally {
64
+ rmSync(dir, { recursive: true, force: true });
65
+ }
66
+ });
67
+
68
+ test("review settings update a CRLF table without duplicating it", () => {
69
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-review-crlf-config-"));
70
+ const path = join(dir, "config.toml");
71
+
72
+ writeFileSync(path, '[review]\r\nskill = "code-review"\r\nworkspace = "worktree"\r\n');
73
+ try {
74
+ persistReviewWorkspace("current", path);
75
+
76
+ const text = readFileSync(path, "utf8");
77
+
78
+ expect(loadConfig({ userConfigPath: path }).review.workspace).toBe("current");
79
+ expect(text.match(/^\[review\]/gm)).toHaveLength(1);
80
+ } finally {
81
+ rmSync(dir, { recursive: true, force: true });
82
+ }
83
+ });
84
+ test("defaults when no file exists", () => {
85
+ // Act
86
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
87
+
88
+ // Assert
89
+ expect(config.keys["comment"]).toEqual(["c"]);
90
+ expect(config.theme.accent).toBe(DARK.accent);
91
+ });
92
+
93
+ test("user config rebinds actions and overrides theme tokens", () => {
94
+ // Arrange
95
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-"));
96
+ const path = join(dir, "config.toml");
97
+
98
+ writeFileSync(
99
+ path,
100
+ `[keys]\ncomment = "a"\nsubmit = ["return", "S"]\n\n[theme]\naccent = "#ff0000"\n`,
101
+ );
102
+
103
+ try {
104
+ // Act
105
+ const config = loadConfig({ userConfigPath: path });
106
+
107
+ // Assert
108
+ expect(config.keys["comment"]).toEqual(["a"]);
109
+ expect(config.keys["submit"]).toEqual(["return", "S"]);
110
+ expect(config.keys["cut"]).toEqual(["x"]); // untouched defaults survive
111
+ expect(config.theme.accent).toBe("#ff0000");
112
+ expect(config.theme.green).toBe(DARK.green);
113
+ } finally {
114
+ rmSync(dir, { recursive: true, force: true });
115
+ }
116
+ });
117
+
118
+ test("repo config layers over user config", () => {
119
+ // Arrange
120
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg2-"));
121
+ const user = join(dir, "user.toml");
122
+ const repoRoot = join(dir, "repo");
123
+
124
+ writeFileSync(user, `[keys]\ncomment = "a"\n`);
125
+ Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
126
+ writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[keys]\ncomment = "z"\n`);
127
+
128
+ try {
129
+ // Act
130
+ const config = loadConfig({ userConfigPath: user, repoRoot });
131
+
132
+ // Assert
133
+ expect(config.keys["comment"]).toEqual(["z"]);
134
+ } finally {
135
+ rmSync(dir, { recursive: true, force: true });
136
+ }
137
+ });
138
+
139
+ test("broken config never blocks a review", () => {
140
+ // Arrange
141
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg3-"));
142
+ const path = join(dir, "config.toml");
143
+
144
+ writeFileSync(path, "not [valid toml");
145
+
146
+ try {
147
+ // Act
148
+ const config = loadConfig({ userConfigPath: path });
149
+
150
+ // Assert
151
+ expect(config.keys["comment"]).toEqual(["c"]);
152
+ } finally {
153
+ rmSync(dir, { recursive: true, force: true });
154
+ }
155
+ });
156
+
157
+ test("[ui] parses auto_close and the editor override", () => {
158
+ // Arrange
159
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg4-"));
160
+ const path = join(dir, "config.toml");
161
+
162
+ writeFileSync(path, `[ui]\nauto_close = 3\neditor = "code --wait"\n`);
163
+
164
+ try {
165
+ // Act
166
+ const config = loadConfig({ userConfigPath: path });
167
+
168
+ // Assert
169
+ expect(config.ui.autoClose).toBe(3);
170
+ expect(config.ui.editor).toBe("code --wait");
171
+ } finally {
172
+ rmSync(dir, { recursive: true, force: true });
173
+ }
174
+ });
175
+
176
+ test("[ui] editor defaults to undefined and ignores a blank value", () => {
177
+ // Arrange
178
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg5-"));
179
+ const path = join(dir, "config.toml");
180
+
181
+ writeFileSync(path, `[ui]\neditor = " "\n`);
182
+
183
+ try {
184
+ // Assert
185
+ expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.editor).toBeUndefined();
186
+ expect(loadConfig({ userConfigPath: path }).ui.editor).toBeUndefined();
187
+ } finally {
188
+ rmSync(dir, { recursive: true, force: true });
189
+ }
190
+ });
191
+
192
+ test("[ui] diff_view defaults to split and parses stacked", () => {
193
+ // Arrange
194
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-diff-"));
195
+ const path = join(dir, "config.toml");
196
+
197
+ writeFileSync(path, `[ui]\ndiff_view = "stacked"\n`);
198
+
199
+ try {
200
+ // Assert
201
+ expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.diffView).toBe("split");
202
+ expect(loadConfig({ userConfigPath: path }).ui.diffView).toBe("stacked");
203
+ } finally {
204
+ rmSync(dir, { recursive: true, force: true });
205
+ }
206
+ });
207
+
208
+ test("[ui] default_message defaults to approve and parses changes_requested", () => {
209
+ // Arrange
210
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-message-"));
211
+ const path = join(dir, "config.toml");
212
+
213
+ writeFileSync(path, `[ui]\ndefault_message = "changes_requested"\n`);
214
+
215
+ try {
216
+ // Assert
217
+ expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.defaultMessage).toBe(
218
+ "approved",
219
+ );
220
+ expect(loadConfig({ userConfigPath: path }).ui.defaultMessage).toBe("changes_requested");
221
+ } finally {
222
+ rmSync(dir, { recursive: true, force: true });
223
+ }
224
+ });
225
+
226
+ test("[skills] path overrides the default and a malformed value never discards the config", () => {
227
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-skills-"));
228
+ const path = join(dir, "config.toml");
229
+
230
+ try {
231
+ writeFileSync(path, `[skills]\npath = "/custom/skills"\n`);
232
+ expect(loadConfig({ userConfigPath: path }).skillsPath).toBe("/custom/skills");
233
+
234
+ // a wrong-typed path falls back to the default without dropping the sibling [keys] setting
235
+ writeFileSync(path, `[skills]\npath = 123\n\n[keys]\ncomment = "z"\n`);
236
+ const config = loadConfig({ userConfigPath: path });
237
+
238
+ expect(config.keys["comment"]).toEqual(["z"]);
239
+ expect(config.skillsPath).toContain(".agents");
240
+ } finally {
241
+ rmSync(dir, { recursive: true, force: true });
242
+ }
243
+ });
244
+
245
+ test('[ui] diff_view loads the pre-rename "unified" value as "stacked"', () => {
246
+ // Arrange - an upgrade must not flip a user who had persisted the old spelling
247
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-diff-"));
248
+ const path = join(dir, "config.toml");
249
+
250
+ writeFileSync(path, `[ui]\ndiff_view = "unified"\n`);
251
+
252
+ try {
253
+ // Assert
254
+ expect(loadConfig({ userConfigPath: path }).ui.diffView).toBe("stacked");
255
+ } finally {
256
+ rmSync(dir, { recursive: true, force: true });
257
+ }
258
+ });
259
+
260
+ test("[ui] theme defaults to the branded cueloop preset", () => {
261
+ // Act
262
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
263
+
264
+ // Assert
265
+ expect(config.ui.theme).toBe("cueloop");
266
+ expect(config.theme.accent).toBe(DARK.accent);
267
+ });
268
+
269
+ test("[ui] theme selects a named preset as the token base", () => {
270
+ // Arrange
271
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme-"));
272
+ const path = join(dir, "config.toml");
273
+
274
+ writeFileSync(path, `[ui]\ntheme = "nord"\n`);
275
+
276
+ try {
277
+ // Act
278
+ const config = loadConfig({ userConfigPath: path });
279
+
280
+ // Assert
281
+ expect(config.ui.theme).toBe("nord");
282
+ expect(config.theme).toEqual(themeForName("nord"));
283
+ } finally {
284
+ rmSync(dir, { recursive: true, force: true });
285
+ }
286
+ });
287
+
288
+ test("[theme] token overrides layer on top of the selected preset", () => {
289
+ // Arrange
290
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme2-"));
291
+ const path = join(dir, "config.toml");
292
+
293
+ writeFileSync(path, `[ui]\ntheme = "nord"\n\n[theme]\naccent = "#ff0000"\n`);
294
+
295
+ try {
296
+ // Act
297
+ const config = loadConfig({ userConfigPath: path });
298
+
299
+ // Assert
300
+ expect(config.theme.accent).toBe("#ff0000"); // override wins
301
+ expect(config.theme.background).toBe(themeForName("nord").background); // preset base survives
302
+ expect(config.themeOverrides).toEqual({ accent: "#ff0000" }); // deltas exposed for live re-compose
303
+ } finally {
304
+ rmSync(dir, { recursive: true, force: true });
305
+ }
306
+ });
307
+
308
+ test("a later file's preset keeps an earlier file's [theme] override", () => {
309
+ // Arrange - user overrides accent; repo picks a preset in a separate file
310
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme-x-"));
311
+ const user = join(dir, "user.toml");
312
+ const repoRoot = join(dir, "repo");
313
+
314
+ writeFileSync(user, `[theme]\naccent = "#ff0000"\n`);
315
+ Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
316
+ writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[ui]\ntheme = "nord"\n`);
317
+
318
+ try {
319
+ // Act
320
+ const config = loadConfig({ userConfigPath: user, repoRoot });
321
+
322
+ // Assert - the preset is the base, but the earlier override still wins its token
323
+ expect(config.ui.theme).toBe("nord");
324
+ expect(config.theme.background).toBe(themeForName("nord").background);
325
+ expect(config.theme.accent).toBe("#ff0000");
326
+ } finally {
327
+ rmSync(dir, { recursive: true, force: true });
328
+ }
329
+ });
330
+
331
+ test("[ui] ignores an unknown theme name and keeps the default", () => {
332
+ // Arrange
333
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme3-"));
334
+ const path = join(dir, "config.toml");
335
+
336
+ writeFileSync(path, `[ui]\ntheme = "solarized-galaxy"\n`);
337
+
338
+ try {
339
+ // Act
340
+ const config = loadConfig({ userConfigPath: path });
341
+
342
+ // Assert
343
+ expect(config.ui.theme).toBe("cueloop");
344
+ expect(config.theme.accent).toBe(DARK.accent);
345
+ } finally {
346
+ rmSync(dir, { recursive: true, force: true });
347
+ }
348
+ });
349
+
350
+ test("persistTheme round-trips through the config file", () => {
351
+ // Arrange
352
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme4-"));
353
+ const path = join(dir, "config.toml");
354
+
355
+ try {
356
+ // Act
357
+ persistTheme("tokyo-night", path);
358
+
359
+ // Assert
360
+ expect(loadConfig({ userConfigPath: path }).ui.theme).toBe("tokyo-night");
361
+
362
+ // a second write replaces the key in place
363
+ // Act
364
+ persistTheme("gruvbox-dark", path);
365
+
366
+ // Assert
367
+ expect(loadConfig({ userConfigPath: path }).ui.theme).toBe("gruvbox-dark");
368
+ } finally {
369
+ rmSync(dir, { recursive: true, force: true });
370
+ }
371
+ });
372
+
373
+ test("persistPins round-trips the pinned-thread ids through the config file", () => {
374
+ // Arrange
375
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-pins-"));
376
+ const path = join(dir, "config.toml");
377
+
378
+ try {
379
+ // Act
380
+ persistPins(["s_one", "s_two"], path);
381
+
382
+ // Assert
383
+ expect(loadConfig({ userConfigPath: path }).ui.pins).toEqual(["s_one", "s_two"]);
384
+
385
+ // unpinning rewrites the list in place, including down to empty
386
+ // Act
387
+ persistPins(["s_two"], path);
388
+
389
+ // Assert
390
+ expect(loadConfig({ userConfigPath: path }).ui.pins).toEqual(["s_two"]);
391
+
392
+ // Act
393
+ persistPins([], path);
394
+
395
+ // Assert
396
+ expect(loadConfig({ userConfigPath: path }).ui.pins).toEqual([]);
397
+ } finally {
398
+ rmSync(dir, { recursive: true, force: true });
399
+ }
400
+ });
401
+
402
+ test("persistLayout round-trips the remembered pane composition through the config file", () => {
403
+ // Arrange
404
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-layout-"));
405
+ const path = join(dir, "config.toml");
406
+
407
+ try {
408
+ // Act
409
+ persistLayout({ threads: false, rightSidebar: "project", zoomChanges: false }, path);
410
+
411
+ // Assert
412
+ expect(loadConfig({ userConfigPath: path }).ui.layout).toEqual({
413
+ threads: false,
414
+ rightSidebar: "project",
415
+ zoomChanges: false,
416
+ });
417
+
418
+ // Act
419
+ persistLayout({ threads: true, rightSidebar: "off", zoomChanges: true }, path);
420
+
421
+ // Assert
422
+ expect(loadConfig({ userConfigPath: path }).ui.layout).toEqual({
423
+ threads: true,
424
+ rightSidebar: "off",
425
+ zoomChanges: true,
426
+ });
427
+ } finally {
428
+ rmSync(dir, { recursive: true, force: true });
429
+ }
430
+ });
431
+
432
+ test("layout is unset until the user changes one", () => {
433
+ // Assert
434
+ expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.layout).toBeUndefined();
435
+ });
436
+ });
437
+
438
+ describe("integrations.obsidian config", () => {
439
+ test("defaults when the section is absent", () => {
440
+ // Act
441
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
442
+
443
+ // Assert
444
+ expect(config.integrations.obsidian.vault).toBeUndefined();
445
+ expect(config.integrations.obsidian.folder).toBe("cueloop");
446
+ expect(config.integrations.obsidian.filenameFormat).toBe("{YYYY}-{MM}-{DD} - {title}");
447
+ expect(config.integrations.obsidian.separator).toBe("space");
448
+ expect(config.integrations.obsidian.exportOn).toBe("manual");
449
+ });
450
+
451
+ test("user config sets the section; invalid enum values are ignored", () => {
452
+ // Arrange
453
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs-"));
454
+ const path = join(dir, "config.toml");
455
+
456
+ writeFileSync(
457
+ path,
458
+ `[integrations.obsidian]\nvault = "/notes/vault"\nfolder = "plans"\nexportOn = "approved"\nseparator = "comma"\n`,
459
+ );
460
+
461
+ try {
462
+ // Act
463
+ const config = loadConfig({ userConfigPath: path });
464
+
465
+ // Assert
466
+ expect(config.integrations.obsidian.vault).toBe("/notes/vault");
467
+ expect(config.integrations.obsidian.folder).toBe("plans");
468
+ expect(config.integrations.obsidian.exportOn).toBe("approved");
469
+ expect(config.integrations.obsidian.separator).toBe("space"); // invalid value falls back
470
+ expect(config.keys["comment"]).toEqual(["c"]); // other sections untouched
471
+ } finally {
472
+ rmSync(dir, { recursive: true, force: true });
473
+ }
474
+ });
475
+
476
+ test("repo config layers over user config for the section", () => {
477
+ // Arrange
478
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs2-"));
479
+ const user = join(dir, "user.toml");
480
+ const repoRoot = join(dir, "repo");
481
+
482
+ writeFileSync(user, `[integrations.obsidian]\nvault = "/user/vault"\nexportOn = "message"\n`);
483
+ Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
484
+ writeFileSync(
485
+ join(repoRoot, ".cueloop", "config.toml"),
486
+ `[integrations.obsidian]\nfolder = "repo-plans"\n`,
487
+ );
488
+
489
+ try {
490
+ // Act
491
+ const config = loadConfig({ userConfigPath: user, repoRoot });
492
+
493
+ // Assert
494
+ expect(config.integrations.obsidian.vault).toBe("/user/vault"); // user layer survives
495
+ expect(config.integrations.obsidian.exportOn).toBe("message");
496
+ expect(config.integrations.obsidian.folder).toBe("repo-plans"); // repo layer wins
497
+ } finally {
498
+ rmSync(dir, { recursive: true, force: true });
499
+ }
500
+ });
501
+ });
502
+
503
+ describe("quick actions ([[actions]])", () => {
504
+ test("absent config keeps the built-in defaults", () => {
505
+ // Act
506
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
507
+
508
+ // Assert
509
+ expect(config.actions).toEqual(DEFAULT_QUICK_ACTIONS);
510
+ expect(config.actions).toHaveLength(8);
511
+ });
512
+
513
+ test("configured actions replace the defaults, with optional metadata", () => {
514
+ // Arrange
515
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-actions-"));
516
+ const path = join(dir, "config.toml");
517
+
518
+ writeFileSync(
519
+ path,
520
+ `[[actions]]\nprompt = "Add a benchmark"\n\n[[actions]]\nprompt = "Guard the edge case"\nmetadata = "null and empty input"\n`,
521
+ );
522
+
523
+ try {
524
+ // Act
525
+ const config = loadConfig({ userConfigPath: path });
526
+
527
+ // Assert
528
+ expect(config.actions).toEqual([
529
+ { prompt: "Add a benchmark" },
530
+ { prompt: "Guard the edge case", metadata: "null and empty input" },
531
+ ]);
532
+ } finally {
533
+ rmSync(dir, { recursive: true, force: true });
534
+ }
535
+ });
536
+
537
+ test("a table missing prompt is skipped; the rest replace the defaults", () => {
538
+ // Arrange
539
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-actions2-"));
540
+ const path = join(dir, "config.toml");
541
+
542
+ writeFileSync(
543
+ path,
544
+ `[[actions]]\nmetadata = "orphan without a prompt"\n\n[[actions]]\nprompt = "Keep this one"\n`,
545
+ );
546
+
547
+ try {
548
+ // Act
549
+ const config = loadConfig({ userConfigPath: path });
550
+
551
+ // Assert
552
+ expect(config.actions).toEqual([{ prompt: "Keep this one" }]);
553
+ } finally {
554
+ rmSync(dir, { recursive: true, force: true });
555
+ }
556
+ });
557
+ });
558
+
559
+ describe("persistActions", () => {
560
+ test("round-trips the action set through [[actions]] and replaces prior ones", () => {
561
+ // Arrange
562
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-persist-actions-"));
563
+ const path = join(dir, "config.toml");
564
+
565
+ writeFileSync(path, `[[actions]]\nprompt = "old one"\n\n[ui]\nauto_close = 3\n`);
566
+
567
+ try {
568
+ // Act
569
+ persistActions(
570
+ [{ prompt: "Zoom out" }, { prompt: "Prototype", metadata: "skip tests and polish" }],
571
+ path,
572
+ );
573
+ const config = loadConfig({ userConfigPath: path });
574
+
575
+ // Assert - the old action is gone, the new set is read back, and [ui] survives
576
+ expect(config.actions).toEqual([
577
+ { prompt: "Zoom out" },
578
+ { prompt: "Prototype", metadata: "skip tests and polish" },
579
+ ]);
580
+ expect(config.ui.autoClose).toBe(3);
581
+ } finally {
582
+ rmSync(dir, { recursive: true, force: true });
583
+ }
584
+ });
585
+ });
586
+
587
+ describe("quickActionBody", () => {
588
+ test("appends the system prompt below the prompt when set", () => {
589
+ // Assert
590
+ expect(quickActionBody({ prompt: "Prototype this", metadata: "skip tests" })).toBe(
591
+ "Prototype this\n\nskip tests",
592
+ );
593
+ expect(quickActionBody({ prompt: "Out of scope" })).toBe("Out of scope");
594
+ });
595
+ });
596
+
597
+ describe("resolveQuickAction", () => {
598
+ test("resolves by 1-based index and by case-insensitive prompt", () => {
599
+ // Assert
600
+ expect(resolveQuickAction(DEFAULT_QUICK_ACTIONS, "1")).toEqual(DEFAULT_QUICK_ACTIONS[0]!);
601
+ expect(resolveQuickAction(DEFAULT_QUICK_ACTIONS, "out of scope")).toEqual(
602
+ DEFAULT_QUICK_ACTIONS[2]!,
603
+ );
604
+ });
605
+
606
+ test("returns undefined for an out-of-range index or an unknown name", () => {
607
+ // Assert
608
+ expect(resolveQuickAction(DEFAULT_QUICK_ACTIONS, "0")).toBeUndefined();
609
+ expect(resolveQuickAction(DEFAULT_QUICK_ACTIONS, "99")).toBeUndefined();
610
+ expect(resolveQuickAction(DEFAULT_QUICK_ACTIONS, "no such action")).toBeUndefined();
611
+ });
612
+ });
613
+
614
+ describe("actionFor", () => {
615
+ test("resolves keys and shifted keys to actions", () => {
616
+ // Assert
617
+ expect(actionFor(DEFAULT_KEYS, "j", false)).toBe("down");
618
+ expect(actionFor(DEFAULT_KEYS, "g", true)).toBe("bottom"); // G
619
+ expect(actionFor(DEFAULT_KEYS, "return", false)).toBe("submit");
620
+ expect(actionFor(DEFAULT_KEYS, "zz", false)).toBeUndefined();
621
+ });
622
+ });
623
+
624
+ describe("[authors] rename map", () => {
625
+ test("loads quoted fingerprint keys", () => {
626
+ // Arrange
627
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-authors-"));
628
+ const path = join(dir, "config.toml");
629
+
630
+ writeFileSync(path, `[authors]\n"SHA256:abc+def/gh" = "Alex"\n`);
631
+
632
+ // Act
633
+ const config = loadConfig({ userConfigPath: path });
634
+
635
+ // Assert
636
+ expect(config.authors["SHA256:abc+def/gh"]).toBe("Alex");
637
+ rmSync(dir, { recursive: true, force: true });
638
+ });
639
+
640
+ test("persistAuthorName round-trips and updates a fingerprint id in place", () => {
641
+ // Arrange
642
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-authors2-"));
643
+ const path = join(dir, "config.toml");
644
+
645
+ // Act / Assert
646
+ persistAuthorName("SHA256:abc+def/gh", "Alex", path);
647
+ expect(loadConfig({ userConfigPath: path }).authors["SHA256:abc+def/gh"]).toBe("Alex");
648
+ persistAuthorName("SHA256:abc+def/gh", "Alexa", path);
649
+ expect(loadConfig({ userConfigPath: path }).authors["SHA256:abc+def/gh"]).toBe("Alexa");
650
+ rmSync(dir, { recursive: true, force: true });
651
+ });
652
+ });
653
+
654
+ describe("the /lgtm default quick action", () => {
655
+ test("expands to a terse approving note", () => {
656
+ const lgtm = DEFAULT_QUICK_ACTIONS.find((action) => action.prompt.toLowerCase() === "lgtm");
657
+
658
+ expect(lgtm).toBeDefined();
659
+ // the reference is /lgtm, so the label is the prompt; it expands like every action (label, then note)
660
+ expect(quickActionBody(lgtm!)).toBe("LGTM\n\nThis looks good to me.");
661
+ });
662
+ });
663
+
664
+ test("Thread agents default off and require a boolean experimental TOML flag", () => {
665
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-agent-config-"));
666
+ const path = join(dir, "config.toml");
667
+
668
+ try {
669
+ expect(loadConfig({ userConfigPath: path }).experimental.threadAgent).toBe(false);
670
+ writeFileSync(path, "[experimental]\nthread_agent = true\n");
671
+ expect(loadConfig({ userConfigPath: path }).experimental.threadAgent).toBe(true);
672
+ writeFileSync(path, '[experimental]\nthread_agent = "true"\n');
673
+ expect(loadConfig({ userConfigPath: path }).experimental.threadAgent).toBe(false);
674
+ } finally {
675
+ rmSync(dir, { recursive: true, force: true });
676
+ }
677
+ });
678
+
679
+ test("Thread harness preference persists without changing experimental or other settings", async () => {
680
+ const { persistThreadHarness } = await import("./config");
681
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-thread-config-"));
682
+ const path = join(dir, "config.toml");
683
+
684
+ try {
685
+ writeFileSync(path, '[experimental]\nthread_agent = true\n[ui]\ntheme = "cueloop"\n');
686
+ persistThreadHarness("fx", path);
687
+ const config = loadConfig({ userConfigPath: path });
688
+
689
+ expect(config.thread.harness).toBe("fx");
690
+ expect(config.experimental.threadAgent).toBe(true);
691
+ expect(config.ui.theme).toBe("cueloop");
692
+ persistThreadHarness("pi", path);
693
+ expect(loadConfig({ userConfigPath: path }).thread.harness).toBe("pi");
694
+ } finally {
695
+ rmSync(dir, { recursive: true, force: true });
696
+ }
697
+ });
698
+
699
+ test("a malformed Thread section does not discard other preferences", () => {
700
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-invalid-thread-config-"));
701
+ const path = join(dir, "config.toml");
702
+
703
+ try {
704
+ for (const section of ['thread = "fx"\n', '[thread]\nharness = "typo"\n']) {
705
+ writeFileSync(
706
+ path,
707
+ `${section}[experimental]\nthread_agent = true\n[ui]\ntheme = "cueloop"\n`,
708
+ );
709
+ const config = loadConfig({ userConfigPath: path });
710
+
711
+ expect(config.thread.harness).toBe("pi");
712
+ expect(config.experimental.threadAgent).toBe(true);
713
+ expect(config.ui.theme).toBe("cueloop");
714
+ }
715
+ } finally {
716
+ rmSync(dir, { recursive: true, force: true });
717
+ }
718
+ });
719
+
720
+ test("Thread model defaults layer independently and persistence stays in the selected table", () => {
721
+ const directory = mkdtempSync(join(tmpdir(), "cueloop-model-defaults-"));
722
+ const user = join(directory, "user.toml");
723
+ const repository = join(directory, "repo");
724
+
725
+ try {
726
+ const defaults = loadConfig({ userConfigPath: user }).thread;
727
+
728
+ expect(defaults.pi).toEqual({ model: "openai-codex/gpt-6.1-sol", effort: "medium" });
729
+ expect(defaults.fx).toEqual({ model: "grok/grok-4.7", effort: "medium" });
730
+ writeFileSync(
731
+ user,
732
+ '[thread.pi]\nmodel = "openai-codex/custom"\neffort = "high"\n\n[thread.fx]\nmodel = "grok/custom"\neffort = "low"\n',
733
+ );
734
+ persistThreadModelDefault("pi", "effort", "medium", user);
735
+ persistThreadModelDefault("fx", "model", "grok/grok-4.7", user);
736
+
737
+ const config = loadConfig({ userConfigPath: user });
738
+
739
+ expect(config.thread.pi).toEqual({ model: "openai-codex/custom", effort: "medium" });
740
+ expect(config.thread.fx).toEqual({ model: "grok/grok-4.7", effort: "low" });
741
+ mkdirSync(join(repository, ".cueloop"), { recursive: true });
742
+ writeFileSync(
743
+ join(repository, ".cueloop/config.toml"),
744
+ '[thread.pi]\neffort = "high"\n[thread.fx]\nmodel = " "\neffort = "invalid"\n',
745
+ );
746
+
747
+ const layered = loadConfig({ userConfigPath: user, repoRoot: repository });
748
+
749
+ expect(layered.thread.pi).toEqual({ model: "openai-codex/custom", effort: "high" });
750
+ expect(layered.thread.fx).toEqual(config.thread.fx);
751
+ } finally {
752
+ rmSync(directory, { recursive: true, force: true });
753
+ }
754
+ });