@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.70

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