@cueloop/client 0.1.0-alpha.6 → 0.1.0-alpha.61

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 (161) 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 +37 -33
  8. package/src/App.appearance.test.tsx +51 -0
  9. package/src/App.autoclose.test.tsx +73 -47
  10. package/src/App.diff.test.tsx +169 -22
  11. package/src/App.export.test.tsx +49 -37
  12. package/src/App.plan-review.test.tsx +714 -0
  13. package/src/App.prototype.test.tsx +194 -0
  14. package/src/App.readonly.test.tsx +69 -35
  15. package/src/App.rendering.test.tsx +59 -31
  16. package/src/App.review-panel.test.tsx +143 -0
  17. package/src/App.submit-confirm.test.tsx +229 -0
  18. package/src/App.test.tsx +139 -68
  19. package/src/App.toast.test.tsx +77 -0
  20. package/src/App.tsx +0 -0
  21. package/src/App.walk.test.tsx +299 -0
  22. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  23. package/src/app-key-state.ts +61 -0
  24. package/src/app-screens.tsx +270 -0
  25. package/src/app-view-model.ts +307 -0
  26. package/src/attribution.test.ts +31 -0
  27. package/src/attribution.ts +42 -0
  28. package/src/clipboard.test.ts +23 -0
  29. package/src/clipboard.ts +33 -0
  30. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  31. package/src/components/AnnotationCard.stories.tsx +94 -0
  32. package/src/components/AnnotationCard.tsx +250 -0
  33. package/src/components/Breadcrumb.stories.tsx +19 -0
  34. package/src/components/Breadcrumb.tsx +45 -0
  35. package/src/components/CodeBlock.stories.tsx +42 -0
  36. package/src/components/CodeBlock.tsx +84 -0
  37. package/src/components/CompletionOverlay.stories.tsx +23 -0
  38. package/src/components/CompletionOverlay.tsx +60 -0
  39. package/src/components/ConfirmCard.stories.tsx +35 -0
  40. package/src/components/ConfirmCard.tsx +129 -0
  41. package/src/components/ConfirmDialog.stories.tsx +21 -0
  42. package/src/components/ConfirmDialog.tsx +66 -0
  43. package/src/components/DiffSheet.scroll.test.tsx +135 -0
  44. package/src/components/DiffSheet.stories.tsx +78 -0
  45. package/src/components/DiffSheet.syntax.test.tsx +65 -0
  46. package/src/components/DiffSheet.tsx +374 -0
  47. package/src/components/InboxList.stories.tsx +31 -0
  48. package/src/components/InboxList.tsx +64 -0
  49. package/src/components/KeybindsDialog.stories.tsx +31 -0
  50. package/src/components/KeybindsDialog.tsx +59 -0
  51. package/src/components/MarkerPopover.stories.tsx +58 -0
  52. package/src/components/MarkerPopover.tsx +109 -0
  53. package/src/components/MenuBar.stories.tsx +37 -0
  54. package/src/components/MenuBar.tsx +75 -0
  55. package/src/components/PlanSheet.stories.tsx +113 -0
  56. package/src/components/PlanSheet.tsx +422 -0
  57. package/src/components/PromptDialog.stories.tsx +36 -0
  58. package/src/components/PromptDialog.tsx +80 -0
  59. package/src/components/PrototypeSheet.stories.tsx +20 -0
  60. package/src/components/PrototypeSheet.tsx +450 -0
  61. package/src/components/ReviewPanel.stories.tsx +86 -0
  62. package/src/components/ReviewPanel.tsx +103 -0
  63. package/src/components/ReviewRail.stories.tsx +218 -0
  64. package/src/components/ReviewRail.tsx +318 -0
  65. package/src/components/SettingsDialog.stories.tsx +111 -0
  66. package/src/components/SettingsDialog.tsx +147 -0
  67. package/src/components/SettingsRows.stories.tsx +18 -0
  68. package/src/components/SettingsRows.tsx +112 -0
  69. package/src/components/Toast.stories.tsx +12 -0
  70. package/src/components/Toast.tsx +45 -0
  71. package/src/components/WalkWizard.stories.tsx +100 -0
  72. package/src/components/WalkWizard.tsx +152 -0
  73. package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
  74. package/src/components/agent-launcher.stories.tsx +26 -0
  75. package/src/components/agent-launcher.test.tsx +50 -0
  76. package/src/components/agent-launcher.tsx +149 -0
  77. package/src/components/diff-sheet-layout.test.ts +128 -0
  78. package/src/components/diff-sheet-layout.ts +184 -0
  79. package/src/components/plan-sheet-run-style.ts +90 -0
  80. package/src/components/primitives/Button.stories.tsx +32 -0
  81. package/src/components/primitives/Button.tsx +52 -0
  82. package/src/components/primitives/Card.stories.tsx +25 -0
  83. package/src/components/primitives/Card.tsx +60 -0
  84. package/src/components/primitives/Dialog.stories.tsx +24 -0
  85. package/src/components/primitives/Dialog.tsx +66 -0
  86. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  87. package/src/components/primitives/StatusBar.tsx +23 -0
  88. package/src/components/primitives/Tabs.stories.tsx +29 -0
  89. package/src/components/primitives/Tabs.tsx +102 -0
  90. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  91. package/src/components/primitives/Toolbar.tsx +14 -0
  92. package/src/components/primitives/frame.test.ts +12 -0
  93. package/src/components/primitives/frame.ts +14 -0
  94. package/src/components/quick-actions-editor.stories.tsx +54 -0
  95. package/src/components/quick-actions-editor.tsx +138 -0
  96. package/src/components/stories-app.tsx +124 -0
  97. package/src/components/stories.test.tsx +66 -0
  98. package/src/components/story-fixtures.ts +98 -0
  99. package/src/components/story.ts +75 -0
  100. package/src/components/syntax-highlight.ts +102 -0
  101. package/src/components/terminal-pane.test.tsx +40 -0
  102. package/src/components/terminal-pane.ts +193 -0
  103. package/src/components/theme-context.stories.tsx +46 -0
  104. package/src/components/theme-context.tsx +37 -0
  105. package/src/components/truncate-text.ts +7 -0
  106. package/src/config.test.ts +448 -31
  107. package/src/config.ts +339 -57
  108. package/src/diff-hunk-curate.test.ts +315 -0
  109. package/src/diff-hunk-curate.ts +253 -0
  110. package/src/diff-intraline.test.ts +165 -0
  111. package/src/diff-intraline.ts +146 -0
  112. package/src/diff-syntax.test.ts +96 -0
  113. package/src/diff-syntax.ts +176 -0
  114. package/src/editor.test.ts +149 -0
  115. package/src/editor.ts +135 -9
  116. package/src/ghostty-terminal.test.ts +84 -0
  117. package/src/ghostty-terminal.ts +176 -0
  118. package/src/herdr.test.ts +9 -34
  119. package/src/herdr.ts +17 -21
  120. package/src/index.ts +10 -0
  121. package/src/integrations.test.ts +41 -0
  122. package/src/integrations.ts +32 -0
  123. package/src/intent-dispatch.test.ts +499 -0
  124. package/src/intent-dispatch.ts +585 -0
  125. package/src/key-bindings.test.ts +156 -0
  126. package/src/key-bindings.ts +452 -0
  127. package/src/keymap.test.ts +635 -0
  128. package/src/keymap.ts +404 -0
  129. package/src/kitty-image.test.ts +66 -0
  130. package/src/kitty-image.ts +138 -0
  131. package/src/prototype-browser.test.ts +56 -0
  132. package/src/prototype-browser.ts +289 -0
  133. package/src/pty.test.ts +81 -0
  134. package/src/pty.ts +237 -0
  135. package/src/review-panel.test.ts +113 -0
  136. package/src/review-panel.ts +83 -0
  137. package/src/run.ts +16 -5
  138. package/src/serve.test.ts +25 -9
  139. package/src/serve.ts +19 -8
  140. package/src/session-controller.curate.test.ts +260 -0
  141. package/src/session-controller.share.test.ts +320 -0
  142. package/src/session-controller.ts +993 -0
  143. package/src/share.ts +120 -0
  144. package/src/test-support.test.ts +60 -0
  145. package/src/test-support.ts +197 -0
  146. package/src/theme-presets.test.ts +51 -0
  147. package/src/theme-presets.ts +187 -0
  148. package/src/theme.ts +64 -17
  149. package/src/use-settings-dialog.tsx +275 -0
  150. package/src/version.ts +5 -0
  151. package/src/view-diff.test.ts +22 -8
  152. package/src/view-diff.ts +39 -23
  153. package/src/view-plan.regression.test.ts +60 -0
  154. package/src/view-plan.test.ts +406 -0
  155. package/src/view-plan.ts +554 -0
  156. package/src/walk.test.ts +121 -0
  157. package/src/walk.ts +74 -0
  158. package/src/syntax.test.ts +0 -41
  159. package/src/syntax.ts +0 -88
  160. package/src/view.test.ts +0 -157
  161. package/src/view.ts +0 -0
@@ -2,57 +2,314 @@ 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
+ persistReviewState,
12
+ persistActions,
13
+ persistReviewWidth,
14
+ persistTheme,
15
+ quickActionBody,
16
+ resolveQuickAction,
17
+ } from "./config";
18
+ import { REVIEW_DEFAULT_WIDTH, REVIEW_MAX_WIDTH } from "./review-panel";
6
19
  import { DARK } from "./theme";
20
+ import { themeForName } from "./theme-presets";
7
21
 
8
22
  describe("loadConfig", () => {
9
23
  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);
24
+ // Act
25
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
26
+
27
+ // Assert
28
+ expect(config.keys["comment"]).toEqual(["c"]);
29
+ expect(config.theme.accent).toBe(DARK.accent);
13
30
  });
14
31
 
15
32
  test("user config rebinds actions and overrides theme tokens", () => {
33
+ // Arrange
16
34
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-"));
17
35
  const path = join(dir, "config.toml");
36
+
18
37
  writeFileSync(
19
38
  path,
20
39
  `[keys]\ncomment = "a"\nsubmit = ["return", "S"]\n\n[theme]\naccent = "#ff0000"\n`,
21
40
  );
41
+
22
42
  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);
43
+ // Act
44
+ const config = loadConfig({ userConfigPath: path });
45
+
46
+ // Assert
47
+ expect(config.keys["comment"]).toEqual(["a"]);
48
+ expect(config.keys["submit"]).toEqual(["return", "S"]);
49
+ expect(config.keys["cut"]).toEqual(["x"]); // untouched defaults survive
50
+ expect(config.theme.accent).toBe("#ff0000");
51
+ expect(config.theme.green).toBe(DARK.green);
29
52
  } finally {
30
53
  rmSync(dir, { recursive: true, force: true });
31
54
  }
32
55
  });
33
56
 
34
57
  test("repo config layers over user config", () => {
58
+ // Arrange
35
59
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg2-"));
36
60
  const user = join(dir, "user.toml");
37
61
  const repoRoot = join(dir, "repo");
62
+
38
63
  writeFileSync(user, `[keys]\ncomment = "a"\n`);
39
64
  Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
40
65
  writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[keys]\ncomment = "z"\n`);
66
+
41
67
  try {
42
- const c = loadConfig({ userConfigPath: user, repoRoot });
43
- expect(c.keys["comment"]).toEqual(["z"]);
68
+ // Act
69
+ const config = loadConfig({ userConfigPath: user, repoRoot });
70
+
71
+ // Assert
72
+ expect(config.keys["comment"]).toEqual(["z"]);
44
73
  } finally {
45
74
  rmSync(dir, { recursive: true, force: true });
46
75
  }
47
76
  });
48
77
 
49
78
  test("broken config never blocks a review", () => {
79
+ // Arrange
50
80
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg3-"));
51
81
  const path = join(dir, "config.toml");
82
+
52
83
  writeFileSync(path, "not [valid toml");
84
+
85
+ try {
86
+ // Act
87
+ const config = loadConfig({ userConfigPath: path });
88
+
89
+ // Assert
90
+ expect(config.keys["comment"]).toEqual(["c"]);
91
+ } finally {
92
+ rmSync(dir, { recursive: true, force: true });
93
+ }
94
+ });
95
+
96
+ test("[ui] parses auto_close and the editor override", () => {
97
+ // Arrange
98
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg4-"));
99
+ const path = join(dir, "config.toml");
100
+
101
+ writeFileSync(path, `[ui]\nauto_close = 3\neditor = "code --wait"\n`);
102
+
103
+ try {
104
+ // Act
105
+ const config = loadConfig({ userConfigPath: path });
106
+
107
+ // Assert
108
+ expect(config.ui.autoClose).toBe(3);
109
+ expect(config.ui.editor).toBe("code --wait");
110
+ } finally {
111
+ rmSync(dir, { recursive: true, force: true });
112
+ }
113
+ });
114
+
115
+ test("[ui] editor defaults to undefined and ignores a blank value", () => {
116
+ // Arrange
117
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg5-"));
118
+ const path = join(dir, "config.toml");
119
+
120
+ writeFileSync(path, `[ui]\neditor = " "\n`);
121
+
122
+ try {
123
+ // Assert
124
+ expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.editor).toBeUndefined();
125
+ expect(loadConfig({ userConfigPath: path }).ui.editor).toBeUndefined();
126
+ } finally {
127
+ rmSync(dir, { recursive: true, force: true });
128
+ }
129
+ });
130
+
131
+ test("[ui] review panel defaults to an expanded rail at the default width", () => {
132
+ // Act
133
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
134
+
135
+ // Assert
136
+ expect(config.ui.reviewState).toBe("expanded");
137
+ expect(config.ui.reviewWidth).toBe(REVIEW_DEFAULT_WIDTH);
138
+ });
139
+
140
+ test("[ui] parses review_width (clamped) and review_state", () => {
141
+ // Arrange
142
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg6-"));
143
+ const path = join(dir, "config.toml");
144
+
145
+ writeFileSync(path, `[ui]\nreview_width = 999\nreview_state = "compact"\n`);
146
+
147
+ try {
148
+ // Act
149
+ const config = loadConfig({ userConfigPath: path });
150
+
151
+ // Assert
152
+ expect(config.ui.reviewWidth).toBe(REVIEW_MAX_WIDTH); // out-of-range width clamps
153
+ expect(config.ui.reviewState).toBe("compact");
154
+ } finally {
155
+ rmSync(dir, { recursive: true, force: true });
156
+ }
157
+ });
158
+
159
+ test("[ui] ignores an unknown review_state and a non-numeric width", () => {
160
+ // Arrange
161
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg7-"));
162
+ const path = join(dir, "config.toml");
163
+
164
+ writeFileSync(path, `[ui]\nreview_width = "wide"\nreview_state = "sideways"\n`);
165
+
166
+ try {
167
+ // Act
168
+ const config = loadConfig({ userConfigPath: path });
169
+
170
+ // Assert
171
+ expect(config.ui.reviewWidth).toBe(REVIEW_DEFAULT_WIDTH);
172
+ expect(config.ui.reviewState).toBe("expanded");
173
+ } finally {
174
+ rmSync(dir, { recursive: true, force: true });
175
+ }
176
+ });
177
+
178
+ test("[ui] theme defaults to the branded cueloop preset", () => {
179
+ // Act
180
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
181
+
182
+ // Assert
183
+ expect(config.ui.theme).toBe("cueloop");
184
+ expect(config.theme.accent).toBe(DARK.accent);
185
+ });
186
+
187
+ test("[ui] theme selects a named preset as the token base", () => {
188
+ // Arrange
189
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme-"));
190
+ const path = join(dir, "config.toml");
191
+
192
+ writeFileSync(path, `[ui]\ntheme = "nord"\n`);
193
+
194
+ try {
195
+ // Act
196
+ const config = loadConfig({ userConfigPath: path });
197
+
198
+ // Assert
199
+ expect(config.ui.theme).toBe("nord");
200
+ expect(config.theme).toEqual(themeForName("nord"));
201
+ } finally {
202
+ rmSync(dir, { recursive: true, force: true });
203
+ }
204
+ });
205
+
206
+ test("[theme] token overrides layer on top of the selected preset", () => {
207
+ // Arrange
208
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme2-"));
209
+ const path = join(dir, "config.toml");
210
+
211
+ writeFileSync(path, `[ui]\ntheme = "nord"\n\n[theme]\naccent = "#ff0000"\n`);
212
+
213
+ try {
214
+ // Act
215
+ const config = loadConfig({ userConfigPath: path });
216
+
217
+ // Assert
218
+ expect(config.theme.accent).toBe("#ff0000"); // override wins
219
+ expect(config.theme.background).toBe(themeForName("nord").background); // preset base survives
220
+ expect(config.themeOverrides).toEqual({ accent: "#ff0000" }); // deltas exposed for live re-compose
221
+ } finally {
222
+ rmSync(dir, { recursive: true, force: true });
223
+ }
224
+ });
225
+
226
+ test("a later file's preset keeps an earlier file's [theme] override", () => {
227
+ // Arrange - user overrides accent; repo picks a preset in a separate file
228
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme-x-"));
229
+ const user = join(dir, "user.toml");
230
+ const repoRoot = join(dir, "repo");
231
+
232
+ writeFileSync(user, `[theme]\naccent = "#ff0000"\n`);
233
+ Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
234
+ writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[ui]\ntheme = "nord"\n`);
235
+
53
236
  try {
54
- const c = loadConfig({ userConfigPath: path });
55
- expect(c.keys["comment"]).toEqual(["c"]);
237
+ // Act
238
+ const config = loadConfig({ userConfigPath: user, repoRoot });
239
+
240
+ // Assert - the preset is the base, but the earlier override still wins its token
241
+ expect(config.ui.theme).toBe("nord");
242
+ expect(config.theme.background).toBe(themeForName("nord").background);
243
+ expect(config.theme.accent).toBe("#ff0000");
244
+ } finally {
245
+ rmSync(dir, { recursive: true, force: true });
246
+ }
247
+ });
248
+
249
+ test("[ui] ignores an unknown theme name and keeps the default", () => {
250
+ // Arrange
251
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme3-"));
252
+ const path = join(dir, "config.toml");
253
+
254
+ writeFileSync(path, `[ui]\ntheme = "solarized-galaxy"\n`);
255
+
256
+ try {
257
+ // Act
258
+ const config = loadConfig({ userConfigPath: path });
259
+
260
+ // Assert
261
+ expect(config.ui.theme).toBe("cueloop");
262
+ expect(config.theme.accent).toBe(DARK.accent);
263
+ } finally {
264
+ rmSync(dir, { recursive: true, force: true });
265
+ }
266
+ });
267
+
268
+ test("persistTheme round-trips through the config file", () => {
269
+ // Arrange
270
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme4-"));
271
+ const path = join(dir, "config.toml");
272
+
273
+ try {
274
+ // Act
275
+ persistTheme("tokyo-night", path);
276
+
277
+ // Assert
278
+ expect(loadConfig({ userConfigPath: path }).ui.theme).toBe("tokyo-night");
279
+
280
+ // a second write replaces the key in place
281
+ // Act
282
+ persistTheme("gruvbox-dark", path);
283
+
284
+ // Assert
285
+ expect(loadConfig({ userConfigPath: path }).ui.theme).toBe("gruvbox-dark");
286
+ } finally {
287
+ rmSync(dir, { recursive: true, force: true });
288
+ }
289
+ });
290
+
291
+ test("persistReviewWidth and persistReviewState round-trip through the config file", () => {
292
+ // Arrange
293
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg8-"));
294
+ const path = join(dir, "config.toml");
295
+
296
+ try {
297
+ // Act
298
+ persistReviewWidth(42, path);
299
+ persistReviewState("hidden", path);
300
+
301
+ // Assert
302
+ const config = loadConfig({ userConfigPath: path });
303
+
304
+ expect(config.ui.reviewWidth).toBe(42);
305
+ expect(config.ui.reviewState).toBe("hidden");
306
+
307
+ // a second write replaces the key in place rather than appending a duplicate
308
+ // Act
309
+ persistReviewWidth(30, path);
310
+
311
+ // Assert
312
+ expect(loadConfig({ userConfigPath: path }).ui.reviewWidth).toBe(30);
56
313
  } finally {
57
314
  rmSync(dir, { recursive: true, force: true });
58
315
  }
@@ -61,56 +318,216 @@ describe("loadConfig", () => {
61
318
 
62
319
  describe("integrations.obsidian config", () => {
63
320
  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");
321
+ // Act
322
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
323
+
324
+ // Assert
325
+ expect(config.integrations.obsidian.vault).toBeUndefined();
326
+ expect(config.integrations.obsidian.folder).toBe("cueloop");
327
+ expect(config.integrations.obsidian.filenameFormat).toBe("{YYYY}-{MM}-{DD} - {title}");
328
+ expect(config.integrations.obsidian.separator).toBe("space");
329
+ expect(config.integrations.obsidian.exportOn).toBe("manual");
70
330
  });
71
331
 
72
332
  test("user config sets the section; invalid enum values are ignored", () => {
333
+ // Arrange
73
334
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs-"));
74
335
  const path = join(dir, "config.toml");
336
+
75
337
  writeFileSync(
76
338
  path,
77
339
  `[integrations.obsidian]\nvault = "/notes/vault"\nfolder = "plans"\nexportOn = "approve"\nseparator = "comma"\n`,
78
340
  );
341
+
79
342
  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
343
+ // Act
344
+ const config = loadConfig({ userConfigPath: path });
345
+
346
+ // Assert
347
+ expect(config.integrations.obsidian.vault).toBe("/notes/vault");
348
+ expect(config.integrations.obsidian.folder).toBe("plans");
349
+ expect(config.integrations.obsidian.exportOn).toBe("approve");
350
+ expect(config.integrations.obsidian.separator).toBe("space"); // invalid value falls back
351
+ expect(config.keys["comment"]).toEqual(["c"]); // other sections untouched
86
352
  } finally {
87
353
  rmSync(dir, { recursive: true, force: true });
88
354
  }
89
355
  });
90
356
 
91
357
  test("repo config layers over user config for the section", () => {
358
+ // Arrange
92
359
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs2-"));
93
360
  const user = join(dir, "user.toml");
94
361
  const repoRoot = join(dir, "repo");
362
+
95
363
  writeFileSync(user, `[integrations.obsidian]\nvault = "/user/vault"\nexportOn = "resolve"\n`);
96
364
  Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
97
- writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[integrations.obsidian]\nfolder = "repo-plans"\n`);
365
+ writeFileSync(
366
+ join(repoRoot, ".cueloop", "config.toml"),
367
+ `[integrations.obsidian]\nfolder = "repo-plans"\n`,
368
+ );
369
+
98
370
  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
371
+ // Act
372
+ const config = loadConfig({ userConfigPath: user, repoRoot });
373
+
374
+ // Assert
375
+ expect(config.integrations.obsidian.vault).toBe("/user/vault"); // user layer survives
376
+ expect(config.integrations.obsidian.exportOn).toBe("resolve");
377
+ expect(config.integrations.obsidian.folder).toBe("repo-plans"); // repo layer wins
103
378
  } finally {
104
379
  rmSync(dir, { recursive: true, force: true });
105
380
  }
106
381
  });
107
382
  });
108
383
 
384
+ describe("quick actions ([[actions]])", () => {
385
+ test("absent config keeps the built-in defaults", () => {
386
+ // Act
387
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
388
+
389
+ // Assert
390
+ expect(config.actions).toEqual(DEFAULT_QUICK_ACTIONS);
391
+ expect(config.actions).toHaveLength(7);
392
+ });
393
+
394
+ test("configured actions replace the defaults, with optional metadata", () => {
395
+ // Arrange
396
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-actions-"));
397
+ const path = join(dir, "config.toml");
398
+
399
+ writeFileSync(
400
+ path,
401
+ `[[actions]]\nprompt = "Add a benchmark"\n\n[[actions]]\nprompt = "Guard the edge case"\nmetadata = "null and empty input"\n`,
402
+ );
403
+
404
+ try {
405
+ // Act
406
+ const config = loadConfig({ userConfigPath: path });
407
+
408
+ // Assert
409
+ expect(config.actions).toEqual([
410
+ { prompt: "Add a benchmark" },
411
+ { prompt: "Guard the edge case", metadata: "null and empty input" },
412
+ ]);
413
+ } finally {
414
+ rmSync(dir, { recursive: true, force: true });
415
+ }
416
+ });
417
+
418
+ test("a table missing prompt is skipped; the rest replace the defaults", () => {
419
+ // Arrange
420
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-actions2-"));
421
+ const path = join(dir, "config.toml");
422
+
423
+ writeFileSync(
424
+ path,
425
+ `[[actions]]\nmetadata = "orphan without a prompt"\n\n[[actions]]\nprompt = "Keep this one"\n`,
426
+ );
427
+
428
+ try {
429
+ // Act
430
+ const config = loadConfig({ userConfigPath: path });
431
+
432
+ // Assert
433
+ expect(config.actions).toEqual([{ prompt: "Keep this one" }]);
434
+ } finally {
435
+ rmSync(dir, { recursive: true, force: true });
436
+ }
437
+ });
438
+ });
439
+
440
+ describe("persistActions", () => {
441
+ test("round-trips the action set through [[actions]] and replaces prior ones", () => {
442
+ // Arrange
443
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-persist-actions-"));
444
+ const path = join(dir, "config.toml");
445
+
446
+ writeFileSync(path, `[[actions]]\nprompt = "old one"\n\n[ui]\nauto_close = 3\n`);
447
+
448
+ try {
449
+ // Act
450
+ persistActions(
451
+ [{ prompt: "Zoom out" }, { prompt: "Prototype", metadata: "skip tests and polish" }],
452
+ path,
453
+ );
454
+ const config = loadConfig({ userConfigPath: path });
455
+
456
+ // Assert - the old action is gone, the new set is read back, and [ui] survives
457
+ expect(config.actions).toEqual([
458
+ { prompt: "Zoom out" },
459
+ { prompt: "Prototype", metadata: "skip tests and polish" },
460
+ ]);
461
+ expect(config.ui.autoClose).toBe(3);
462
+ } finally {
463
+ rmSync(dir, { recursive: true, force: true });
464
+ }
465
+ });
466
+ });
467
+
468
+ describe("quickActionBody", () => {
469
+ test("appends the system prompt below the prompt when set", () => {
470
+ // Assert
471
+ expect(quickActionBody({ prompt: "Prototype this", metadata: "skip tests" })).toBe(
472
+ "Prototype this\n\nskip tests",
473
+ );
474
+ expect(quickActionBody({ prompt: "Out of scope" })).toBe("Out of scope");
475
+ });
476
+ });
477
+
478
+ describe("resolveQuickAction", () => {
479
+ test("resolves by 1-based index and by case-insensitive prompt", () => {
480
+ // Assert
481
+ expect(resolveQuickAction(DEFAULT_QUICK_ACTIONS, "1")).toEqual(DEFAULT_QUICK_ACTIONS[0]!);
482
+ expect(resolveQuickAction(DEFAULT_QUICK_ACTIONS, "out of scope")).toEqual(
483
+ DEFAULT_QUICK_ACTIONS[2]!,
484
+ );
485
+ });
486
+
487
+ test("returns undefined for an out-of-range index or an unknown name", () => {
488
+ // Assert
489
+ expect(resolveQuickAction(DEFAULT_QUICK_ACTIONS, "0")).toBeUndefined();
490
+ expect(resolveQuickAction(DEFAULT_QUICK_ACTIONS, "99")).toBeUndefined();
491
+ expect(resolveQuickAction(DEFAULT_QUICK_ACTIONS, "no such action")).toBeUndefined();
492
+ });
493
+ });
494
+
109
495
  describe("actionFor", () => {
110
496
  test("resolves keys and shifted keys to actions", () => {
497
+ // Assert
111
498
  expect(actionFor(DEFAULT_KEYS, "j", false)).toBe("down");
112
499
  expect(actionFor(DEFAULT_KEYS, "g", true)).toBe("bottom"); // G
113
500
  expect(actionFor(DEFAULT_KEYS, "return", false)).toBe("submit");
114
501
  expect(actionFor(DEFAULT_KEYS, "zz", false)).toBeUndefined();
115
502
  });
116
503
  });
504
+
505
+ describe("[authors] rename map", () => {
506
+ test("loads quoted fingerprint keys", () => {
507
+ // Arrange
508
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-authors-"));
509
+ const path = join(dir, "config.toml");
510
+
511
+ writeFileSync(path, `[authors]\n"SHA256:abc+def/gh" = "Alex"\n`);
512
+
513
+ // Act
514
+ const config = loadConfig({ userConfigPath: path });
515
+
516
+ // Assert
517
+ expect(config.authors["SHA256:abc+def/gh"]).toBe("Alex");
518
+ rmSync(dir, { recursive: true, force: true });
519
+ });
520
+
521
+ test("persistAuthorName round-trips and updates a fingerprint id in place", () => {
522
+ // Arrange
523
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-authors2-"));
524
+ const path = join(dir, "config.toml");
525
+
526
+ // Act / Assert
527
+ persistAuthorName("SHA256:abc+def/gh", "Alex", path);
528
+ expect(loadConfig({ userConfigPath: path }).authors["SHA256:abc+def/gh"]).toBe("Alex");
529
+ persistAuthorName("SHA256:abc+def/gh", "Alexa", path);
530
+ expect(loadConfig({ userConfigPath: path }).authors["SHA256:abc+def/gh"]).toBe("Alexa");
531
+ rmSync(dir, { recursive: true, force: true });
532
+ });
533
+ });