@cueloop/client 0.1.0-alpha.8 → 0.1.0-alpha.80

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 (307) 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 +40 -32
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +77 -51
  10. package/src/App.diff.test.tsx +176 -45
  11. package/src/App.export.test.tsx +58 -41
  12. package/src/App.focus.test.ts +89 -0
  13. package/src/App.identity.test.tsx +78 -0
  14. package/src/App.plan-render.test.tsx +209 -0
  15. package/src/App.plan-review.test.tsx +428 -0
  16. package/src/App.prototype-markdown.test.tsx +92 -0
  17. package/src/App.prototype.test.tsx +176 -0
  18. package/src/App.readonly.test.tsx +129 -67
  19. package/src/App.regression.test.tsx +94 -0
  20. package/src/App.rendering.test.tsx +61 -35
  21. package/src/App.share-dialog.test.tsx +172 -0
  22. package/src/App.submit-confirm.test.tsx +222 -0
  23. package/src/App.test.tsx +333 -131
  24. package/src/App.toast.test.tsx +98 -0
  25. package/src/App.tsx +1338 -608
  26. package/src/App.visual.test.tsx +57 -0
  27. package/src/App.walk.test.tsx +299 -0
  28. package/src/App.workbench.test.tsx +701 -0
  29. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  30. package/src/annotation-palette.ts +62 -0
  31. package/src/ansi-screen.test.ts +28 -0
  32. package/src/ansi-screen.ts +72 -0
  33. package/src/app-key-state.ts +61 -0
  34. package/src/app-screens.tsx +574 -0
  35. package/src/app-view-model.ts +162 -0
  36. package/src/attribution.test.ts +31 -0
  37. package/src/attribution.ts +42 -0
  38. package/src/clipboard.test.ts +23 -0
  39. package/src/clipboard.ts +33 -0
  40. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  41. package/src/components/AnnotatableFileView.test.tsx +102 -0
  42. package/src/components/AnnotatableFileView.tsx +134 -0
  43. package/src/components/AnnotationCards.stories.tsx +86 -0
  44. package/src/components/AnnotationCards.tsx +341 -0
  45. package/src/components/AnsiScreen.stories.tsx +12 -0
  46. package/src/components/AnsiScreen.tsx +28 -0
  47. package/src/components/AppHeader.stories.tsx +50 -0
  48. package/src/components/AppHeader.tsx +83 -0
  49. package/src/components/AppShell.brand.test.tsx +58 -0
  50. package/src/components/AppShell.stories.tsx +89 -0
  51. package/src/components/AppShell.tsx +329 -0
  52. package/src/components/AppShell.zoom.test.tsx +135 -0
  53. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  54. package/src/components/BareWorkbenchFileView.tsx +64 -0
  55. package/src/components/Breadcrumb.stories.tsx +19 -0
  56. package/src/components/Breadcrumb.tsx +45 -0
  57. package/src/components/ChangesColumn.stories.tsx +44 -0
  58. package/src/components/ChangesColumn.tsx +199 -0
  59. package/src/components/ChangesFileTree.test.tsx +61 -0
  60. package/src/components/CodeBlock.stories.tsx +42 -0
  61. package/src/components/CodeBlock.tsx +84 -0
  62. package/src/components/CompletionOverlay.stories.tsx +37 -0
  63. package/src/components/CompletionOverlay.tsx +77 -0
  64. package/src/components/ConfirmCard.stories.tsx +37 -0
  65. package/src/components/ConfirmCard.tsx +153 -0
  66. package/src/components/ConfirmDialog.stories.tsx +21 -0
  67. package/src/components/ConfirmDialog.tsx +72 -0
  68. package/src/components/DiffContentView.scroll.test.tsx +121 -0
  69. package/src/components/DiffContentView.split.test.tsx +140 -0
  70. package/src/components/DiffContentView.stories.tsx +80 -0
  71. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  72. package/src/components/DiffContentView.tsx +876 -0
  73. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  74. package/src/components/DiscussionMarkerRail.tsx +186 -0
  75. package/src/components/EditorGrid.stories.tsx +59 -0
  76. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  77. package/src/components/EditorGrid.tsx +426 -0
  78. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  79. package/src/components/FileContentsView.stories.tsx +16 -0
  80. package/src/components/FileContentsView.test.tsx +29 -0
  81. package/src/components/FileContentsView.tsx +98 -0
  82. package/src/components/GridTabContent.stories.tsx +59 -0
  83. package/src/components/GridTabContent.tsx +182 -0
  84. package/src/components/HandleChipsInput.stories.tsx +24 -0
  85. package/src/components/HandleChipsInput.test.tsx +35 -0
  86. package/src/components/HandleChipsInput.tsx +145 -0
  87. package/src/components/KeybindsDialog.stories.tsx +31 -0
  88. package/src/components/KeybindsDialog.tsx +103 -0
  89. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  90. package/src/components/MarkdownGridBlock.tsx +72 -0
  91. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  92. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  93. package/src/components/MarkdownThreadEditor.tsx +162 -0
  94. package/src/components/MenuBar.stories.tsx +28 -0
  95. package/src/components/MenuBar.tsx +44 -0
  96. package/src/components/NavModeHint.stories.tsx +40 -0
  97. package/src/components/NavModeHint.tsx +31 -0
  98. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  99. package/src/components/OverlayScrollbar.test.tsx +139 -0
  100. package/src/components/OverlayScrollbar.tsx +129 -0
  101. package/src/components/PaneGrid.stories.tsx +84 -0
  102. package/src/components/PaneGrid.tsx +128 -0
  103. package/src/components/PanelColumn.stories.tsx +25 -0
  104. package/src/components/PanelColumn.tsx +98 -0
  105. package/src/components/ProjectTreeView.stories.tsx +25 -0
  106. package/src/components/ProjectTreeView.test.tsx +59 -0
  107. package/src/components/ProjectTreeView.tsx +111 -0
  108. package/src/components/PromptDialog.stories.tsx +36 -0
  109. package/src/components/PromptDialog.test.tsx +61 -0
  110. package/src/components/PromptDialog.tsx +99 -0
  111. package/src/components/PrototypeContentView.stories.tsx +19 -0
  112. package/src/components/PrototypeContentView.tsx +446 -0
  113. package/src/components/RootOverlay.stories.tsx +45 -0
  114. package/src/components/RootOverlay.tsx +48 -0
  115. package/src/components/ScrollArea.stories.tsx +20 -0
  116. package/src/components/ScrollArea.tsx +44 -0
  117. package/src/components/SettingsDialog.stories.tsx +117 -0
  118. package/src/components/SettingsDialog.tsx +186 -0
  119. package/src/components/SettingsRows.stories.tsx +18 -0
  120. package/src/components/SettingsRows.tsx +112 -0
  121. package/src/components/ShareDialog.stories.tsx +121 -0
  122. package/src/components/ShareDialog.tsx +552 -0
  123. package/src/components/ShellHeader.stories.tsx +50 -0
  124. package/src/components/ShellHeader.tsx +137 -0
  125. package/src/components/SlashComposer.stories.tsx +37 -0
  126. package/src/components/SlashComposer.tsx +87 -0
  127. package/src/components/ThreadFooter.stories.tsx +30 -0
  128. package/src/components/ThreadFooter.test.tsx +47 -0
  129. package/src/components/ThreadFooter.tsx +76 -0
  130. package/src/components/ThreadTree.stories.tsx +46 -0
  131. package/src/components/ThreadTree.tsx +255 -0
  132. package/src/components/ThreadView.selection.test.tsx +899 -0
  133. package/src/components/ThreadView.slash.test.tsx +146 -0
  134. package/src/components/ThreadView.stories.tsx +26 -0
  135. package/src/components/ThreadView.tsx +472 -0
  136. package/src/components/Toast.stories.tsx +19 -0
  137. package/src/components/Toast.tsx +52 -0
  138. package/src/components/Tooltip.stories.tsx +26 -0
  139. package/src/components/Tooltip.tsx +72 -0
  140. package/src/components/TreePane.stories.tsx +106 -0
  141. package/src/components/TreePane.tsx +157 -0
  142. package/src/components/WalkWizard.stories.tsx +100 -0
  143. package/src/components/WalkWizard.tsx +150 -0
  144. package/src/components/WelcomePlayground.stories.tsx +12 -0
  145. package/src/components/WelcomePlayground.test.tsx +47 -0
  146. package/src/components/WelcomePlayground.tsx +142 -0
  147. package/src/components/__snapshots__/stories.test.tsx.snap +2238 -0
  148. package/src/components/diff-content-view-layout.test.ts +198 -0
  149. package/src/components/diff-content-view-layout.ts +84 -0
  150. package/src/components/editor-grid.test.ts +86 -0
  151. package/src/components/editor-grid.ts +138 -0
  152. package/src/components/file-tree.test.ts +60 -0
  153. package/src/components/file-tree.ts +112 -0
  154. package/src/components/menu-control.stories.tsx +35 -0
  155. package/src/components/menu-control.tsx +43 -0
  156. package/src/components/pages/Workspace.stories.tsx +181 -0
  157. package/src/components/primitives/Button.stories.tsx +32 -0
  158. package/src/components/primitives/Button.tsx +56 -0
  159. package/src/components/primitives/Card.stories.tsx +25 -0
  160. package/src/components/primitives/Card.tsx +63 -0
  161. package/src/components/primitives/Dialog.stories.tsx +24 -0
  162. package/src/components/primitives/Dialog.test.tsx +37 -0
  163. package/src/components/primitives/Dialog.tsx +71 -0
  164. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  165. package/src/components/primitives/DialogActions.tsx +39 -0
  166. package/src/components/primitives/IconButton.stories.tsx +36 -0
  167. package/src/components/primitives/IconButton.tsx +62 -0
  168. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  169. package/src/components/primitives/StatusBar.tsx +23 -0
  170. package/src/components/primitives/Switch.stories.tsx +17 -0
  171. package/src/components/primitives/Switch.tsx +32 -0
  172. package/src/components/primitives/Tabs.stories.tsx +29 -0
  173. package/src/components/primitives/Tabs.tsx +102 -0
  174. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  175. package/src/components/primitives/Toolbar.tsx +14 -0
  176. package/src/components/primitives/Tree.stories.tsx +124 -0
  177. package/src/components/primitives/Tree.tsx +100 -0
  178. package/src/components/primitives/frame.test.ts +12 -0
  179. package/src/components/primitives/frame.ts +17 -0
  180. package/src/components/primitives/icons.ts +61 -0
  181. package/src/components/primitives/tree-model.test.ts +99 -0
  182. package/src/components/primitives/tree-model.ts +137 -0
  183. package/src/components/quick-actions-editor.stories.tsx +58 -0
  184. package/src/components/quick-actions-editor.test.tsx +117 -0
  185. package/src/components/quick-actions-editor.tsx +226 -0
  186. package/src/components/share-dialog-store.ts +58 -0
  187. package/src/components/stories-app.tsx +193 -0
  188. package/src/components/stories.test.tsx +74 -0
  189. package/src/components/story-fixtures.ts +102 -0
  190. package/src/components/story-tree.test.ts +108 -0
  191. package/src/components/story.ts +159 -0
  192. package/src/components/syntax-highlight.ts +133 -0
  193. package/src/components/terminal-pane.test.tsx +40 -0
  194. package/src/components/terminal-pane.ts +199 -0
  195. package/src/components/theme-context.stories.tsx +46 -0
  196. package/src/components/theme-context.tsx +37 -0
  197. package/src/components/thread-tree.test.ts +134 -0
  198. package/src/components/thread-tree.ts +109 -0
  199. package/src/components/truncate-text.ts +7 -0
  200. package/src/components/truncate-title.test.ts +31 -0
  201. package/src/components/truncate-title.ts +15 -0
  202. package/src/config-identity.test.ts +78 -0
  203. package/src/config.test.ts +517 -33
  204. package/src/config.ts +467 -57
  205. package/src/diff-fold.test.ts +78 -0
  206. package/src/diff-fold.ts +126 -0
  207. package/src/diff-hunk-curate.test.ts +315 -0
  208. package/src/diff-hunk-curate.ts +153 -0
  209. package/src/diff-intraline.test.ts +238 -0
  210. package/src/diff-intraline.ts +236 -0
  211. package/src/diff-syntax.test.ts +112 -0
  212. package/src/diff-syntax.ts +179 -0
  213. package/src/discussions.ts +79 -0
  214. package/src/editor.test.ts +149 -0
  215. package/src/editor.ts +139 -9
  216. package/src/ghostty-terminal.test.ts +85 -0
  217. package/src/ghostty-terminal.ts +199 -0
  218. package/src/github-identity.test.ts +58 -0
  219. package/src/github-identity.ts +41 -0
  220. package/src/herdr.test.ts +9 -34
  221. package/src/herdr.ts +25 -21
  222. package/src/index.ts +13 -0
  223. package/src/integrations.test.ts +41 -0
  224. package/src/integrations.ts +32 -0
  225. package/src/intent-dispatch.test.ts +723 -0
  226. package/src/intent-dispatch.ts +692 -0
  227. package/src/key-bindings.test.ts +156 -0
  228. package/src/key-bindings.ts +459 -0
  229. package/src/keymap.test.ts +461 -113
  230. package/src/keymap.ts +321 -79
  231. package/src/kitty-image.test.ts +66 -0
  232. package/src/kitty-image.ts +138 -0
  233. package/src/launch-layout.test.ts +52 -0
  234. package/src/launch-layout.ts +42 -0
  235. package/src/mark-runs.ts +121 -0
  236. package/src/markdown-editor-syntax-style.ts +50 -0
  237. package/src/markdown-grid.test.ts +101 -0
  238. package/src/markdown-grid.ts +203 -0
  239. package/src/markdown-highlight.test.ts +61 -0
  240. package/src/markdown-highlight.ts +122 -0
  241. package/src/pasted-image.test.ts +67 -0
  242. package/src/pasted-image.ts +60 -0
  243. package/src/perf/otlp-trace.test.ts +116 -0
  244. package/src/perf/otlp-trace.ts +130 -0
  245. package/src/perf/perf-report.ts +17 -0
  246. package/src/perf/perf-timings.test.ts +18 -0
  247. package/src/perf/perf-timings.ts +38 -0
  248. package/src/prototype-browser.test.ts +64 -0
  249. package/src/prototype-browser.ts +329 -0
  250. package/src/prototype-pixels.tsx +35 -0
  251. package/src/pty.test.ts +81 -0
  252. package/src/pty.ts +245 -0
  253. package/src/ready-signal.test.ts +85 -0
  254. package/src/ready-signal.ts +52 -0
  255. package/src/run.ts +78 -13
  256. package/src/serve.test.ts +24 -12
  257. package/src/serve.ts +52 -10
  258. package/src/share.ts +237 -0
  259. package/src/skills.test.ts +74 -0
  260. package/src/skills.ts +75 -0
  261. package/src/slash-palette.test.ts +103 -0
  262. package/src/slash-palette.ts +129 -0
  263. package/src/split-diff.test.ts +110 -0
  264. package/src/split-diff.ts +149 -0
  265. package/src/test-support.test.ts +264 -0
  266. package/src/test-support.ts +369 -0
  267. package/src/theme-presets.test.ts +51 -0
  268. package/src/theme-presets.ts +214 -0
  269. package/src/theme.ts +84 -19
  270. package/src/thread-chords.test.ts +79 -0
  271. package/src/thread-chords.ts +163 -0
  272. package/src/thread-controller.curate.test.ts +450 -0
  273. package/src/thread-controller.derivation.test.ts +62 -0
  274. package/src/thread-controller.live-diff.test.ts +322 -0
  275. package/src/thread-controller.optimistic.test.ts +69 -0
  276. package/src/thread-controller.share.test.ts +561 -0
  277. package/src/thread-controller.switch-cache.test.ts +176 -0
  278. package/src/thread-controller.tree.test.ts +261 -0
  279. package/src/thread-controller.ts +1893 -0
  280. package/src/thread-selection.test.ts +210 -0
  281. package/src/thread-selection.ts +243 -0
  282. package/src/tree-view.test.ts +128 -0
  283. package/src/tree-view.ts +148 -0
  284. package/src/use-annotation-surface.tsx +915 -0
  285. package/src/use-changes-workbench.ts +190 -0
  286. package/src/use-frame-measure.ts +41 -0
  287. package/src/use-remember-layout.ts +31 -0
  288. package/src/use-settings-dialog.tsx +320 -0
  289. package/src/use-terminal-virtualizer.ts +233 -0
  290. package/src/use-thread-body-editing.tsx +58 -0
  291. package/src/version.ts +5 -0
  292. package/src/view-diff.test.ts +137 -9
  293. package/src/view-diff.ts +269 -23
  294. package/src/view-plan.regression.test.ts +60 -0
  295. package/src/view-plan.test.ts +456 -0
  296. package/src/view-plan.ts +719 -0
  297. package/src/visual/contrast.test.ts +35 -0
  298. package/src/visual/contrast.ts +33 -0
  299. package/src/walk.test.ts +121 -0
  300. package/src/walk.ts +74 -0
  301. package/src/workbench-snapshot.test.ts +81 -0
  302. package/src/workbench-snapshot.ts +29 -0
  303. package/src/session-controller.ts +0 -345
  304. package/src/syntax.test.ts +0 -41
  305. package/src/syntax.ts +0 -88
  306. package/src/view.test.ts +0 -157
  307. package/src/view.ts +0 -312
@@ -2,115 +2,599 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { DEFAULT_KEYS, actionFor, loadConfig } from "./config";
5
+ import {
6
+ DEFAULT_KEYS,
7
+ DEFAULT_QUICK_ACTIONS,
8
+ actionFor,
9
+ loadConfig,
10
+ persistAuthorName,
11
+ persistActions,
12
+ persistLayout,
13
+ persistPins,
14
+ persistTheme,
15
+ quickActionBody,
16
+ resolveQuickAction,
17
+ } from "./config";
6
18
  import { DARK } from "./theme";
19
+ import { themeForName } from "./theme-presets";
7
20
 
8
21
  describe("loadConfig", () => {
9
22
  test("defaults when no file exists", () => {
10
- const c = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
11
- expect(c.keys["comment"]).toEqual(["c"]);
12
- expect(c.theme.accent).toBe(DARK.accent);
23
+ // Act
24
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
25
+
26
+ // Assert
27
+ expect(config.keys["comment"]).toEqual(["c"]);
28
+ expect(config.theme.accent).toBe(DARK.accent);
13
29
  });
14
30
 
15
31
  test("user config rebinds actions and overrides theme tokens", () => {
32
+ // Arrange
16
33
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-"));
17
34
  const path = join(dir, "config.toml");
35
+
18
36
  writeFileSync(
19
37
  path,
20
38
  `[keys]\ncomment = "a"\nsubmit = ["return", "S"]\n\n[theme]\naccent = "#ff0000"\n`,
21
39
  );
40
+
22
41
  try {
23
- const c = loadConfig({ userConfigPath: path });
24
- expect(c.keys["comment"]).toEqual(["a"]);
25
- expect(c.keys["submit"]).toEqual(["return", "S"]);
26
- expect(c.keys["cut"]).toEqual(["x"]); // untouched defaults survive
27
- expect(c.theme.accent).toBe("#ff0000");
28
- expect(c.theme.green).toBe(DARK.green);
42
+ // Act
43
+ const config = loadConfig({ userConfigPath: path });
44
+
45
+ // Assert
46
+ expect(config.keys["comment"]).toEqual(["a"]);
47
+ expect(config.keys["submit"]).toEqual(["return", "S"]);
48
+ expect(config.keys["cut"]).toEqual(["x"]); // untouched defaults survive
49
+ expect(config.theme.accent).toBe("#ff0000");
50
+ expect(config.theme.green).toBe(DARK.green);
29
51
  } finally {
30
52
  rmSync(dir, { recursive: true, force: true });
31
53
  }
32
54
  });
33
55
 
34
56
  test("repo config layers over user config", () => {
57
+ // Arrange
35
58
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg2-"));
36
59
  const user = join(dir, "user.toml");
37
60
  const repoRoot = join(dir, "repo");
61
+
38
62
  writeFileSync(user, `[keys]\ncomment = "a"\n`);
39
63
  Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
40
64
  writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[keys]\ncomment = "z"\n`);
65
+
41
66
  try {
42
- const c = loadConfig({ userConfigPath: user, repoRoot });
43
- expect(c.keys["comment"]).toEqual(["z"]);
67
+ // Act
68
+ const config = loadConfig({ userConfigPath: user, repoRoot });
69
+
70
+ // Assert
71
+ expect(config.keys["comment"]).toEqual(["z"]);
44
72
  } finally {
45
73
  rmSync(dir, { recursive: true, force: true });
46
74
  }
47
75
  });
48
76
 
49
77
  test("broken config never blocks a review", () => {
78
+ // Arrange
50
79
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg3-"));
51
80
  const path = join(dir, "config.toml");
81
+
52
82
  writeFileSync(path, "not [valid toml");
83
+
84
+ try {
85
+ // Act
86
+ const config = loadConfig({ userConfigPath: path });
87
+
88
+ // Assert
89
+ expect(config.keys["comment"]).toEqual(["c"]);
90
+ } finally {
91
+ rmSync(dir, { recursive: true, force: true });
92
+ }
93
+ });
94
+
95
+ test("[ui] parses auto_close and the editor override", () => {
96
+ // Arrange
97
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg4-"));
98
+ const path = join(dir, "config.toml");
99
+
100
+ writeFileSync(path, `[ui]\nauto_close = 3\neditor = "code --wait"\n`);
101
+
102
+ try {
103
+ // Act
104
+ const config = loadConfig({ userConfigPath: path });
105
+
106
+ // Assert
107
+ expect(config.ui.autoClose).toBe(3);
108
+ expect(config.ui.editor).toBe("code --wait");
109
+ } finally {
110
+ rmSync(dir, { recursive: true, force: true });
111
+ }
112
+ });
113
+
114
+ test("[ui] editor defaults to undefined and ignores a blank value", () => {
115
+ // Arrange
116
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg5-"));
117
+ const path = join(dir, "config.toml");
118
+
119
+ writeFileSync(path, `[ui]\neditor = " "\n`);
120
+
121
+ try {
122
+ // Assert
123
+ expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.editor).toBeUndefined();
124
+ expect(loadConfig({ userConfigPath: path }).ui.editor).toBeUndefined();
125
+ } finally {
126
+ rmSync(dir, { recursive: true, force: true });
127
+ }
128
+ });
129
+
130
+ test("[ui] diff_view defaults to split and parses stacked", () => {
131
+ // Arrange
132
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-diff-"));
133
+ const path = join(dir, "config.toml");
134
+
135
+ writeFileSync(path, `[ui]\ndiff_view = "stacked"\n`);
136
+
137
+ try {
138
+ // Assert
139
+ expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.diffView).toBe("split");
140
+ expect(loadConfig({ userConfigPath: path }).ui.diffView).toBe("stacked");
141
+ } finally {
142
+ rmSync(dir, { recursive: true, force: true });
143
+ }
144
+ });
145
+
146
+ test("[ui] default_message defaults to approve and parses changes_requested", () => {
147
+ // Arrange
148
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-message-"));
149
+ const path = join(dir, "config.toml");
150
+
151
+ writeFileSync(path, `[ui]\ndefault_message = "changes_requested"\n`);
152
+
153
+ try {
154
+ // Assert
155
+ expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.defaultMessage).toBe(
156
+ "approved",
157
+ );
158
+ expect(loadConfig({ userConfigPath: path }).ui.defaultMessage).toBe("changes_requested");
159
+ } finally {
160
+ rmSync(dir, { recursive: true, force: true });
161
+ }
162
+ });
163
+
164
+ test("[skills] path overrides the default and a malformed value never discards the config", () => {
165
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-skills-"));
166
+ const path = join(dir, "config.toml");
167
+
168
+ try {
169
+ writeFileSync(path, `[skills]\npath = "/custom/skills"\n`);
170
+ expect(loadConfig({ userConfigPath: path }).skillsPath).toBe("/custom/skills");
171
+
172
+ // a wrong-typed path falls back to the default without dropping the sibling [keys] setting
173
+ writeFileSync(path, `[skills]\npath = 123\n\n[keys]\ncomment = "z"\n`);
174
+ const config = loadConfig({ userConfigPath: path });
175
+
176
+ expect(config.keys["comment"]).toEqual(["z"]);
177
+ expect(config.skillsPath).toContain(".agents");
178
+ } finally {
179
+ rmSync(dir, { recursive: true, force: true });
180
+ }
181
+ });
182
+
183
+ test('[ui] diff_view loads the pre-rename "unified" value as "stacked"', () => {
184
+ // Arrange - an upgrade must not flip a user who had persisted the old spelling
185
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-diff-"));
186
+ const path = join(dir, "config.toml");
187
+
188
+ writeFileSync(path, `[ui]\ndiff_view = "unified"\n`);
189
+
190
+ try {
191
+ // Assert
192
+ expect(loadConfig({ userConfigPath: path }).ui.diffView).toBe("stacked");
193
+ } finally {
194
+ rmSync(dir, { recursive: true, force: true });
195
+ }
196
+ });
197
+
198
+ test("[ui] theme defaults to the branded cueloop preset", () => {
199
+ // Act
200
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
201
+
202
+ // Assert
203
+ expect(config.ui.theme).toBe("cueloop");
204
+ expect(config.theme.accent).toBe(DARK.accent);
205
+ });
206
+
207
+ test("[ui] theme selects a named preset as the token base", () => {
208
+ // Arrange
209
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme-"));
210
+ const path = join(dir, "config.toml");
211
+
212
+ writeFileSync(path, `[ui]\ntheme = "nord"\n`);
213
+
214
+ try {
215
+ // Act
216
+ const config = loadConfig({ userConfigPath: path });
217
+
218
+ // Assert
219
+ expect(config.ui.theme).toBe("nord");
220
+ expect(config.theme).toEqual(themeForName("nord"));
221
+ } finally {
222
+ rmSync(dir, { recursive: true, force: true });
223
+ }
224
+ });
225
+
226
+ test("[theme] token overrides layer on top of the selected preset", () => {
227
+ // Arrange
228
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme2-"));
229
+ const path = join(dir, "config.toml");
230
+
231
+ writeFileSync(path, `[ui]\ntheme = "nord"\n\n[theme]\naccent = "#ff0000"\n`);
232
+
233
+ try {
234
+ // Act
235
+ const config = loadConfig({ userConfigPath: path });
236
+
237
+ // Assert
238
+ expect(config.theme.accent).toBe("#ff0000"); // override wins
239
+ expect(config.theme.background).toBe(themeForName("nord").background); // preset base survives
240
+ expect(config.themeOverrides).toEqual({ accent: "#ff0000" }); // deltas exposed for live re-compose
241
+ } finally {
242
+ rmSync(dir, { recursive: true, force: true });
243
+ }
244
+ });
245
+
246
+ test("a later file's preset keeps an earlier file's [theme] override", () => {
247
+ // Arrange - user overrides accent; repo picks a preset in a separate file
248
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme-x-"));
249
+ const user = join(dir, "user.toml");
250
+ const repoRoot = join(dir, "repo");
251
+
252
+ writeFileSync(user, `[theme]\naccent = "#ff0000"\n`);
253
+ Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
254
+ writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[ui]\ntheme = "nord"\n`);
255
+
256
+ try {
257
+ // Act
258
+ const config = loadConfig({ userConfigPath: user, repoRoot });
259
+
260
+ // Assert - the preset is the base, but the earlier override still wins its token
261
+ expect(config.ui.theme).toBe("nord");
262
+ expect(config.theme.background).toBe(themeForName("nord").background);
263
+ expect(config.theme.accent).toBe("#ff0000");
264
+ } finally {
265
+ rmSync(dir, { recursive: true, force: true });
266
+ }
267
+ });
268
+
269
+ test("[ui] ignores an unknown theme name and keeps the default", () => {
270
+ // Arrange
271
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme3-"));
272
+ const path = join(dir, "config.toml");
273
+
274
+ writeFileSync(path, `[ui]\ntheme = "solarized-galaxy"\n`);
275
+
53
276
  try {
54
- const c = loadConfig({ userConfigPath: path });
55
- expect(c.keys["comment"]).toEqual(["c"]);
277
+ // Act
278
+ const config = loadConfig({ userConfigPath: path });
279
+
280
+ // Assert
281
+ expect(config.ui.theme).toBe("cueloop");
282
+ expect(config.theme.accent).toBe(DARK.accent);
56
283
  } finally {
57
284
  rmSync(dir, { recursive: true, force: true });
58
285
  }
59
286
  });
287
+
288
+ test("persistTheme round-trips through the config file", () => {
289
+ // Arrange
290
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme4-"));
291
+ const path = join(dir, "config.toml");
292
+
293
+ try {
294
+ // Act
295
+ persistTheme("tokyo-night", path);
296
+
297
+ // Assert
298
+ expect(loadConfig({ userConfigPath: path }).ui.theme).toBe("tokyo-night");
299
+
300
+ // a second write replaces the key in place
301
+ // Act
302
+ persistTheme("gruvbox-dark", path);
303
+
304
+ // Assert
305
+ expect(loadConfig({ userConfigPath: path }).ui.theme).toBe("gruvbox-dark");
306
+ } finally {
307
+ rmSync(dir, { recursive: true, force: true });
308
+ }
309
+ });
310
+
311
+ test("persistPins round-trips the pinned-thread ids through the config file", () => {
312
+ // Arrange
313
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-pins-"));
314
+ const path = join(dir, "config.toml");
315
+
316
+ try {
317
+ // Act
318
+ persistPins(["s_one", "s_two"], path);
319
+
320
+ // Assert
321
+ expect(loadConfig({ userConfigPath: path }).ui.pins).toEqual(["s_one", "s_two"]);
322
+
323
+ // unpinning rewrites the list in place, including down to empty
324
+ // Act
325
+ persistPins(["s_two"], path);
326
+
327
+ // Assert
328
+ expect(loadConfig({ userConfigPath: path }).ui.pins).toEqual(["s_two"]);
329
+
330
+ // Act
331
+ persistPins([], path);
332
+
333
+ // Assert
334
+ expect(loadConfig({ userConfigPath: path }).ui.pins).toEqual([]);
335
+ } finally {
336
+ rmSync(dir, { recursive: true, force: true });
337
+ }
338
+ });
339
+
340
+ test("persistLayout round-trips the remembered pane composition through the config file", () => {
341
+ // Arrange
342
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-layout-"));
343
+ const path = join(dir, "config.toml");
344
+
345
+ try {
346
+ // Act
347
+ persistLayout({ threads: false, rightSidebar: "project", zoomChanges: false }, path);
348
+
349
+ // Assert
350
+ expect(loadConfig({ userConfigPath: path }).ui.layout).toEqual({
351
+ threads: false,
352
+ rightSidebar: "project",
353
+ zoomChanges: false,
354
+ });
355
+
356
+ // Act
357
+ persistLayout({ threads: true, rightSidebar: "off", zoomChanges: true }, path);
358
+
359
+ // Assert
360
+ expect(loadConfig({ userConfigPath: path }).ui.layout).toEqual({
361
+ threads: true,
362
+ rightSidebar: "off",
363
+ zoomChanges: true,
364
+ });
365
+ } finally {
366
+ rmSync(dir, { recursive: true, force: true });
367
+ }
368
+ });
369
+
370
+ test("layout is unset until the user changes one", () => {
371
+ // Assert
372
+ expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.layout).toBeUndefined();
373
+ });
60
374
  });
61
375
 
62
376
  describe("integrations.obsidian config", () => {
63
377
  test("defaults when the section is absent", () => {
64
- const c = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
65
- expect(c.integrations.obsidian.vault).toBeUndefined();
66
- expect(c.integrations.obsidian.folder).toBe("cueloop");
67
- expect(c.integrations.obsidian.filenameFormat).toBe("{YYYY}-{MM}-{DD} - {title}");
68
- expect(c.integrations.obsidian.separator).toBe("space");
69
- expect(c.integrations.obsidian.exportOn).toBe("manual");
378
+ // Act
379
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
380
+
381
+ // Assert
382
+ expect(config.integrations.obsidian.vault).toBeUndefined();
383
+ expect(config.integrations.obsidian.folder).toBe("cueloop");
384
+ expect(config.integrations.obsidian.filenameFormat).toBe("{YYYY}-{MM}-{DD} - {title}");
385
+ expect(config.integrations.obsidian.separator).toBe("space");
386
+ expect(config.integrations.obsidian.exportOn).toBe("manual");
70
387
  });
71
388
 
72
389
  test("user config sets the section; invalid enum values are ignored", () => {
390
+ // Arrange
73
391
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs-"));
74
392
  const path = join(dir, "config.toml");
393
+
75
394
  writeFileSync(
76
395
  path,
77
- `[integrations.obsidian]\nvault = "/notes/vault"\nfolder = "plans"\nexportOn = "approve"\nseparator = "comma"\n`,
396
+ `[integrations.obsidian]\nvault = "/notes/vault"\nfolder = "plans"\nexportOn = "approved"\nseparator = "comma"\n`,
78
397
  );
398
+
79
399
  try {
80
- const c = loadConfig({ userConfigPath: path });
81
- expect(c.integrations.obsidian.vault).toBe("/notes/vault");
82
- expect(c.integrations.obsidian.folder).toBe("plans");
83
- expect(c.integrations.obsidian.exportOn).toBe("approve");
84
- expect(c.integrations.obsidian.separator).toBe("space"); // invalid value falls back
85
- expect(c.keys["comment"]).toEqual(["c"]); // other sections untouched
400
+ // Act
401
+ const config = loadConfig({ userConfigPath: path });
402
+
403
+ // Assert
404
+ expect(config.integrations.obsidian.vault).toBe("/notes/vault");
405
+ expect(config.integrations.obsidian.folder).toBe("plans");
406
+ expect(config.integrations.obsidian.exportOn).toBe("approved");
407
+ expect(config.integrations.obsidian.separator).toBe("space"); // invalid value falls back
408
+ expect(config.keys["comment"]).toEqual(["c"]); // other sections untouched
86
409
  } finally {
87
410
  rmSync(dir, { recursive: true, force: true });
88
411
  }
89
412
  });
90
413
 
91
414
  test("repo config layers over user config for the section", () => {
415
+ // Arrange
92
416
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs2-"));
93
417
  const user = join(dir, "user.toml");
94
418
  const repoRoot = join(dir, "repo");
95
- writeFileSync(user, `[integrations.obsidian]\nvault = "/user/vault"\nexportOn = "resolve"\n`);
419
+
420
+ writeFileSync(user, `[integrations.obsidian]\nvault = "/user/vault"\nexportOn = "message"\n`);
96
421
  Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
97
- writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[integrations.obsidian]\nfolder = "repo-plans"\n`);
422
+ writeFileSync(
423
+ join(repoRoot, ".cueloop", "config.toml"),
424
+ `[integrations.obsidian]\nfolder = "repo-plans"\n`,
425
+ );
426
+
98
427
  try {
99
- const c = loadConfig({ userConfigPath: user, repoRoot });
100
- expect(c.integrations.obsidian.vault).toBe("/user/vault"); // user layer survives
101
- expect(c.integrations.obsidian.exportOn).toBe("resolve");
102
- expect(c.integrations.obsidian.folder).toBe("repo-plans"); // repo layer wins
428
+ // Act
429
+ const config = loadConfig({ userConfigPath: user, repoRoot });
430
+
431
+ // Assert
432
+ expect(config.integrations.obsidian.vault).toBe("/user/vault"); // user layer survives
433
+ expect(config.integrations.obsidian.exportOn).toBe("message");
434
+ expect(config.integrations.obsidian.folder).toBe("repo-plans"); // repo layer wins
103
435
  } finally {
104
436
  rmSync(dir, { recursive: true, force: true });
105
437
  }
106
438
  });
107
439
  });
108
440
 
441
+ describe("quick actions ([[actions]])", () => {
442
+ test("absent config keeps the built-in defaults", () => {
443
+ // Act
444
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
445
+
446
+ // Assert
447
+ expect(config.actions).toEqual(DEFAULT_QUICK_ACTIONS);
448
+ expect(config.actions).toHaveLength(8);
449
+ });
450
+
451
+ test("configured actions replace the defaults, with optional metadata", () => {
452
+ // Arrange
453
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-actions-"));
454
+ const path = join(dir, "config.toml");
455
+
456
+ writeFileSync(
457
+ path,
458
+ `[[actions]]\nprompt = "Add a benchmark"\n\n[[actions]]\nprompt = "Guard the edge case"\nmetadata = "null and empty input"\n`,
459
+ );
460
+
461
+ try {
462
+ // Act
463
+ const config = loadConfig({ userConfigPath: path });
464
+
465
+ // Assert
466
+ expect(config.actions).toEqual([
467
+ { prompt: "Add a benchmark" },
468
+ { prompt: "Guard the edge case", metadata: "null and empty input" },
469
+ ]);
470
+ } finally {
471
+ rmSync(dir, { recursive: true, force: true });
472
+ }
473
+ });
474
+
475
+ test("a table missing prompt is skipped; the rest replace the defaults", () => {
476
+ // Arrange
477
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-actions2-"));
478
+ const path = join(dir, "config.toml");
479
+
480
+ writeFileSync(
481
+ path,
482
+ `[[actions]]\nmetadata = "orphan without a prompt"\n\n[[actions]]\nprompt = "Keep this one"\n`,
483
+ );
484
+
485
+ try {
486
+ // Act
487
+ const config = loadConfig({ userConfigPath: path });
488
+
489
+ // Assert
490
+ expect(config.actions).toEqual([{ prompt: "Keep this one" }]);
491
+ } finally {
492
+ rmSync(dir, { recursive: true, force: true });
493
+ }
494
+ });
495
+ });
496
+
497
+ describe("persistActions", () => {
498
+ test("round-trips the action set through [[actions]] and replaces prior ones", () => {
499
+ // Arrange
500
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-persist-actions-"));
501
+ const path = join(dir, "config.toml");
502
+
503
+ writeFileSync(path, `[[actions]]\nprompt = "old one"\n\n[ui]\nauto_close = 3\n`);
504
+
505
+ try {
506
+ // Act
507
+ persistActions(
508
+ [{ prompt: "Zoom out" }, { prompt: "Prototype", metadata: "skip tests and polish" }],
509
+ path,
510
+ );
511
+ const config = loadConfig({ userConfigPath: path });
512
+
513
+ // Assert - the old action is gone, the new set is read back, and [ui] survives
514
+ expect(config.actions).toEqual([
515
+ { prompt: "Zoom out" },
516
+ { prompt: "Prototype", metadata: "skip tests and polish" },
517
+ ]);
518
+ expect(config.ui.autoClose).toBe(3);
519
+ } finally {
520
+ rmSync(dir, { recursive: true, force: true });
521
+ }
522
+ });
523
+ });
524
+
525
+ describe("quickActionBody", () => {
526
+ test("appends the system prompt below the prompt when set", () => {
527
+ // Assert
528
+ expect(quickActionBody({ prompt: "Prototype this", metadata: "skip tests" })).toBe(
529
+ "Prototype this\n\nskip tests",
530
+ );
531
+ expect(quickActionBody({ prompt: "Out of scope" })).toBe("Out of scope");
532
+ });
533
+ });
534
+
535
+ describe("resolveQuickAction", () => {
536
+ test("resolves by 1-based index and by case-insensitive prompt", () => {
537
+ // Assert
538
+ expect(resolveQuickAction(DEFAULT_QUICK_ACTIONS, "1")).toEqual(DEFAULT_QUICK_ACTIONS[0]!);
539
+ expect(resolveQuickAction(DEFAULT_QUICK_ACTIONS, "out of scope")).toEqual(
540
+ DEFAULT_QUICK_ACTIONS[2]!,
541
+ );
542
+ });
543
+
544
+ test("returns undefined for an out-of-range index or an unknown name", () => {
545
+ // Assert
546
+ expect(resolveQuickAction(DEFAULT_QUICK_ACTIONS, "0")).toBeUndefined();
547
+ expect(resolveQuickAction(DEFAULT_QUICK_ACTIONS, "99")).toBeUndefined();
548
+ expect(resolveQuickAction(DEFAULT_QUICK_ACTIONS, "no such action")).toBeUndefined();
549
+ });
550
+ });
551
+
109
552
  describe("actionFor", () => {
110
553
  test("resolves keys and shifted keys to actions", () => {
554
+ // Assert
111
555
  expect(actionFor(DEFAULT_KEYS, "j", false)).toBe("down");
112
556
  expect(actionFor(DEFAULT_KEYS, "g", true)).toBe("bottom"); // G
113
557
  expect(actionFor(DEFAULT_KEYS, "return", false)).toBe("submit");
114
558
  expect(actionFor(DEFAULT_KEYS, "zz", false)).toBeUndefined();
115
559
  });
116
560
  });
561
+
562
+ describe("[authors] rename map", () => {
563
+ test("loads quoted fingerprint keys", () => {
564
+ // Arrange
565
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-authors-"));
566
+ const path = join(dir, "config.toml");
567
+
568
+ writeFileSync(path, `[authors]\n"SHA256:abc+def/gh" = "Alex"\n`);
569
+
570
+ // Act
571
+ const config = loadConfig({ userConfigPath: path });
572
+
573
+ // Assert
574
+ expect(config.authors["SHA256:abc+def/gh"]).toBe("Alex");
575
+ rmSync(dir, { recursive: true, force: true });
576
+ });
577
+
578
+ test("persistAuthorName round-trips and updates a fingerprint id in place", () => {
579
+ // Arrange
580
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-authors2-"));
581
+ const path = join(dir, "config.toml");
582
+
583
+ // Act / Assert
584
+ persistAuthorName("SHA256:abc+def/gh", "Alex", path);
585
+ expect(loadConfig({ userConfigPath: path }).authors["SHA256:abc+def/gh"]).toBe("Alex");
586
+ persistAuthorName("SHA256:abc+def/gh", "Alexa", path);
587
+ expect(loadConfig({ userConfigPath: path }).authors["SHA256:abc+def/gh"]).toBe("Alexa");
588
+ rmSync(dir, { recursive: true, force: true });
589
+ });
590
+ });
591
+
592
+ describe("the /lgtm default quick action", () => {
593
+ test("expands to a terse approving note", () => {
594
+ const lgtm = DEFAULT_QUICK_ACTIONS.find((action) => action.prompt.toLowerCase() === "lgtm");
595
+
596
+ expect(lgtm).toBeDefined();
597
+ // the reference is /lgtm, so the label is the prompt; it expands like every action (label, then note)
598
+ expect(quickActionBody(lgtm!)).toBe("LGTM\n\nThis looks good to me.");
599
+ });
600
+ });