@cueloop/client 0.1.0-alpha.5 → 0.1.0-alpha.51

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