@cueloop/client 0.1.0-alpha.33 → 0.1.0-alpha.34

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 (94) hide show
  1. package/package.json +27 -26
  2. package/src/App.autoclose.test.tsx +7 -4
  3. package/src/App.diff.test.tsx +130 -4
  4. package/src/App.export.test.tsx +22 -4
  5. package/src/App.plan-review.test.tsx +127 -27
  6. package/src/App.readonly.test.tsx +31 -8
  7. package/src/App.rendering.test.tsx +12 -3
  8. package/src/App.review-panel.test.tsx +11 -3
  9. package/src/App.submit-confirm.test.tsx +12 -7
  10. package/src/App.test.tsx +46 -9
  11. package/src/App.toast.test.tsx +10 -3
  12. package/src/App.tsx +400 -100
  13. package/src/App.walk.test.tsx +30 -10
  14. package/src/attribution.ts +8 -2
  15. package/src/clipboard.ts +5 -1
  16. package/src/components/AnnotationCard.stories.tsx +28 -9
  17. package/src/components/AnnotationCard.tsx +53 -38
  18. package/src/components/Breadcrumb.tsx +7 -1
  19. package/src/components/CodeBlock.stories.tsx +19 -2
  20. package/src/components/CodeBlock.tsx +24 -9
  21. package/src/components/CompletionOverlay.tsx +13 -3
  22. package/src/components/ConfirmCard.stories.tsx +22 -3
  23. package/src/components/ConfirmCard.tsx +22 -5
  24. package/src/components/ConfirmDialog.tsx +9 -1
  25. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  26. package/src/components/DiffSheet.stories.tsx +18 -2
  27. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  28. package/src/components/DiffSheet.tsx +184 -98
  29. package/src/components/InboxList.stories.tsx +16 -3
  30. package/src/components/InboxList.tsx +17 -10
  31. package/src/components/KeybindsDialog.tsx +19 -3
  32. package/src/components/MarkerPopover.stories.tsx +58 -0
  33. package/src/components/MarkerPopover.tsx +102 -0
  34. package/src/components/MenuBar.stories.tsx +5 -1
  35. package/src/components/MenuBar.tsx +18 -2
  36. package/src/components/PlanSheet.stories.tsx +34 -2
  37. package/src/components/PlanSheet.tsx +141 -32
  38. package/src/components/PromptDialog.tsx +18 -2
  39. package/src/components/ReviewPanel.stories.tsx +13 -3
  40. package/src/components/ReviewPanel.tsx +8 -4
  41. package/src/components/ReviewRail.stories.tsx +145 -72
  42. package/src/components/ReviewRail.tsx +177 -38
  43. package/src/components/SettingsDialog.stories.tsx +51 -3
  44. package/src/components/SettingsDialog.tsx +8 -3
  45. package/src/components/SettingsRows.tsx +19 -3
  46. package/src/components/Toast.tsx +17 -2
  47. package/src/components/WalkWizard.stories.tsx +13 -2
  48. package/src/components/WalkWizard.tsx +9 -3
  49. package/src/components/__snapshots__/stories.test.tsx.snap +281 -83
  50. package/src/components/diff-sheet-layout.test.ts +127 -0
  51. package/src/components/diff-sheet-layout.ts +167 -0
  52. package/src/components/primitives/Button.tsx +5 -1
  53. package/src/components/primitives/Dialog.tsx +9 -1
  54. package/src/components/primitives/Tabs.tsx +24 -4
  55. package/src/components/stories-app.tsx +34 -6
  56. package/src/components/story-fixtures.ts +11 -3
  57. package/src/components/story.ts +3 -1
  58. package/src/components/syntax-highlight.ts +47 -12
  59. package/src/components/theme-context.stories.tsx +6 -1
  60. package/src/components/theme-context.tsx +7 -1
  61. package/src/config.test.ts +178 -2
  62. package/src/config.ts +121 -23
  63. package/src/diff-hunk-curate.test.ts +315 -0
  64. package/src/diff-hunk-curate.ts +233 -0
  65. package/src/diff-intraline.test.ts +164 -0
  66. package/src/diff-intraline.ts +132 -0
  67. package/src/diff-syntax.test.ts +96 -0
  68. package/src/diff-syntax.ts +154 -0
  69. package/src/editor.test.ts +13 -4
  70. package/src/editor.ts +31 -5
  71. package/src/herdr.ts +8 -2
  72. package/src/index.ts +9 -1
  73. package/src/integrations.ts +3 -1
  74. package/src/intent-dispatch.test.ts +193 -9
  75. package/src/intent-dispatch.ts +151 -18
  76. package/src/key-bindings.test.ts +19 -9
  77. package/src/key-bindings.ts +36 -14
  78. package/src/keymap.test.ts +171 -26
  79. package/src/keymap.ts +83 -16
  80. package/src/review-panel.test.ts +3 -1
  81. package/src/review-panel.ts +9 -2
  82. package/src/serve.test.ts +11 -3
  83. package/src/session-controller.curate.test.ts +253 -0
  84. package/src/session-controller.share.test.ts +65 -17
  85. package/src/session-controller.ts +301 -26
  86. package/src/share.ts +41 -8
  87. package/src/test-support.ts +16 -6
  88. package/src/theme-presets.ts +168 -0
  89. package/src/theme.ts +1 -3
  90. package/src/view-diff.ts +15 -4
  91. package/src/view-plan.test.ts +40 -6
  92. package/src/view-plan.ts +71 -28
  93. package/src/walk.test.ts +7 -2
  94. package/src/walk.ts +7 -2
@@ -2,9 +2,19 @@ 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, persistAuthorName, persistReviewState, persistReviewWidth } from "./config";
5
+ import {
6
+ DEFAULT_KEYS,
7
+ DEFAULT_QUICK_ACTIONS,
8
+ actionFor,
9
+ loadConfig,
10
+ persistAuthorName,
11
+ persistReviewState,
12
+ persistReviewWidth,
13
+ persistTheme,
14
+ } from "./config";
6
15
  import { REVIEW_DEFAULT_WIDTH, REVIEW_MAX_WIDTH } from "./review-panel";
7
16
  import { DARK } from "./theme";
17
+ import { themeForName } from "./theme-presets";
8
18
 
9
19
  describe("loadConfig", () => {
10
20
  test("defaults when no file exists", () => {
@@ -155,6 +165,115 @@ describe("loadConfig", () => {
155
165
  }
156
166
  });
157
167
 
168
+ test("[ui] theme defaults to the branded cueloop preset", () => {
169
+ // Act
170
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
171
+
172
+ // Assert
173
+ expect(config.ui.theme).toBe("cueloop");
174
+ expect(config.theme.accent).toBe(DARK.accent);
175
+ });
176
+
177
+ test("[ui] theme selects a named preset as the token base", () => {
178
+ // Arrange
179
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme-"));
180
+ const path = join(dir, "config.toml");
181
+ writeFileSync(path, `[ui]\ntheme = "nord"\n`);
182
+
183
+ try {
184
+ // Act
185
+ const config = loadConfig({ userConfigPath: path });
186
+
187
+ // Assert
188
+ expect(config.ui.theme).toBe("nord");
189
+ expect(config.theme).toEqual(themeForName("nord"));
190
+ } finally {
191
+ rmSync(dir, { recursive: true, force: true });
192
+ }
193
+ });
194
+
195
+ test("[theme] token overrides layer on top of the selected preset", () => {
196
+ // Arrange
197
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme2-"));
198
+ const path = join(dir, "config.toml");
199
+ writeFileSync(path, `[ui]\ntheme = "nord"\n\n[theme]\naccent = "#ff0000"\n`);
200
+
201
+ try {
202
+ // Act
203
+ const config = loadConfig({ userConfigPath: path });
204
+
205
+ // Assert
206
+ expect(config.theme.accent).toBe("#ff0000"); // override wins
207
+ expect(config.theme.background).toBe(themeForName("nord").background); // preset base survives
208
+ expect(config.themeOverrides).toEqual({ accent: "#ff0000" }); // deltas exposed for live re-compose
209
+ } finally {
210
+ rmSync(dir, { recursive: true, force: true });
211
+ }
212
+ });
213
+
214
+ test("a later file's preset keeps an earlier file's [theme] override", () => {
215
+ // Arrange - user overrides accent; repo picks a preset in a separate file
216
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme-x-"));
217
+ const user = join(dir, "user.toml");
218
+ const repoRoot = join(dir, "repo");
219
+ writeFileSync(user, `[theme]\naccent = "#ff0000"\n`);
220
+ Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
221
+ writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[ui]\ntheme = "nord"\n`);
222
+
223
+ try {
224
+ // Act
225
+ const config = loadConfig({ userConfigPath: user, repoRoot });
226
+
227
+ // Assert - the preset is the base, but the earlier override still wins its token
228
+ expect(config.ui.theme).toBe("nord");
229
+ expect(config.theme.background).toBe(themeForName("nord").background);
230
+ expect(config.theme.accent).toBe("#ff0000");
231
+ } finally {
232
+ rmSync(dir, { recursive: true, force: true });
233
+ }
234
+ });
235
+
236
+ test("[ui] ignores an unknown theme name and keeps the default", () => {
237
+ // Arrange
238
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme3-"));
239
+ const path = join(dir, "config.toml");
240
+ writeFileSync(path, `[ui]\ntheme = "solarized-galaxy"\n`);
241
+
242
+ try {
243
+ // Act
244
+ const config = loadConfig({ userConfigPath: path });
245
+
246
+ // Assert
247
+ expect(config.ui.theme).toBe("cueloop");
248
+ expect(config.theme.accent).toBe(DARK.accent);
249
+ } finally {
250
+ rmSync(dir, { recursive: true, force: true });
251
+ }
252
+ });
253
+
254
+ test("persistTheme round-trips through the config file", () => {
255
+ // Arrange
256
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-theme4-"));
257
+ const path = join(dir, "config.toml");
258
+
259
+ try {
260
+ // Act
261
+ persistTheme("tokyo-night", path);
262
+
263
+ // Assert
264
+ expect(loadConfig({ userConfigPath: path }).ui.theme).toBe("tokyo-night");
265
+
266
+ // a second write replaces the key in place
267
+ // Act
268
+ persistTheme("gruvbox-dark", path);
269
+
270
+ // Assert
271
+ expect(loadConfig({ userConfigPath: path }).ui.theme).toBe("gruvbox-dark");
272
+ } finally {
273
+ rmSync(dir, { recursive: true, force: true });
274
+ }
275
+ });
276
+
158
277
  test("persistReviewWidth and persistReviewState round-trip through the config file", () => {
159
278
  // Arrange
160
279
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg8-"));
@@ -226,7 +345,10 @@ describe("integrations.obsidian config", () => {
226
345
  const repoRoot = join(dir, "repo");
227
346
  writeFileSync(user, `[integrations.obsidian]\nvault = "/user/vault"\nexportOn = "resolve"\n`);
228
347
  Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
229
- writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[integrations.obsidian]\nfolder = "repo-plans"\n`);
348
+ writeFileSync(
349
+ join(repoRoot, ".cueloop", "config.toml"),
350
+ `[integrations.obsidian]\nfolder = "repo-plans"\n`,
351
+ );
230
352
 
231
353
  try {
232
354
  // Act
@@ -242,6 +364,60 @@ describe("integrations.obsidian config", () => {
242
364
  });
243
365
  });
244
366
 
367
+ describe("quick actions ([[actions]])", () => {
368
+ test("absent config keeps the built-in defaults", () => {
369
+ // Act
370
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
371
+
372
+ // Assert
373
+ expect(config.actions).toEqual(DEFAULT_QUICK_ACTIONS);
374
+ expect(config.actions).toHaveLength(7);
375
+ });
376
+
377
+ test("configured actions replace the defaults, with optional metadata", () => {
378
+ // Arrange
379
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-actions-"));
380
+ const path = join(dir, "config.toml");
381
+ writeFileSync(
382
+ path,
383
+ `[[actions]]\nprompt = "Add a benchmark"\n\n[[actions]]\nprompt = "Guard the edge case"\nmetadata = "null and empty input"\n`,
384
+ );
385
+
386
+ try {
387
+ // Act
388
+ const config = loadConfig({ userConfigPath: path });
389
+
390
+ // Assert
391
+ expect(config.actions).toEqual([
392
+ { prompt: "Add a benchmark" },
393
+ { prompt: "Guard the edge case", metadata: "null and empty input" },
394
+ ]);
395
+ } finally {
396
+ rmSync(dir, { recursive: true, force: true });
397
+ }
398
+ });
399
+
400
+ test("a table missing prompt is skipped; the rest replace the defaults", () => {
401
+ // Arrange
402
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-actions2-"));
403
+ const path = join(dir, "config.toml");
404
+ writeFileSync(
405
+ path,
406
+ `[[actions]]\nmetadata = "orphan without a prompt"\n\n[[actions]]\nprompt = "Keep this one"\n`,
407
+ );
408
+
409
+ try {
410
+ // Act
411
+ const config = loadConfig({ userConfigPath: path });
412
+
413
+ // Assert
414
+ expect(config.actions).toEqual([{ prompt: "Keep this one" }]);
415
+ } finally {
416
+ rmSync(dir, { recursive: true, force: true });
417
+ }
418
+ });
419
+ });
420
+
245
421
  describe("actionFor", () => {
246
422
  test("resolves keys and shifted keys to actions", () => {
247
423
  // Assert
package/src/config.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Layered TOML config: built-in defaults → user config → trusted repo
3
3
  * config → env. Sections: [keys] action = "combo" (every action rebindable),
4
- * [theme] per-token overrides, [ui] auto_close + editor + the review-panel
5
- * layout (review_width + review_state), [integrations.obsidian] notes-vault
6
- * export.
4
+ * [theme] per-token overrides, [ui] auto_close + editor + theme (a named
5
+ * preset) + the review-panel layout (review_width + review_state),
6
+ * [integrations.obsidian] notes-vault export.
7
7
  */
8
8
 
9
9
  import { existsSync, readFileSync } from "node:fs";
@@ -13,6 +13,7 @@ import { OBSIDIAN_DEFAULTS, type ObsidianConfig } from "@cueloop/integration-obs
13
13
  import { writeFileSync, mkdirSync } from "node:fs";
14
14
  import { dirname } from "node:path";
15
15
  import { DARK, type Theme } from "./theme";
16
+ import { DEFAULT_THEME_NAME, isThemeName, themeForName, type ThemeName } from "./theme-presets";
16
17
  import { REVIEW_DEFAULT_WIDTH, clampWidth, type ReviewPanelMode } from "./review-panel";
17
18
 
18
19
  export interface KeymapConfig {
@@ -26,16 +27,44 @@ export interface IntegrationsConfig {
26
27
  /** Post-submit behavior: "off" prompts, 0 closes instantly, N counts down. */
27
28
  export type AutoClose = "off" | number;
28
29
 
30
+ /** One marker-popover quick action: a preset comment body, plus optional extra lines. */
31
+ export interface QuickAction {
32
+ prompt: string;
33
+ metadata?: string;
34
+ }
35
+
36
+ /** The built-in quick-actions for the marker popover when no `[[actions]]` are configured. */
37
+ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
38
+ { prompt: "Zoom out, research in depth" },
39
+ { prompt: "Restate simplified" },
40
+ { prompt: "Out of scope" },
41
+ { prompt: "Let's chat about this" },
42
+ { prompt: "Prototype this" },
43
+ { prompt: "Ensure 0 regressions" },
44
+ { prompt: "Consider existing repo patterns" },
45
+ ];
46
+
29
47
  export interface CueloopConfig {
30
48
  keys: Record<string, string[]>;
31
49
  theme: Theme;
50
+ /** The `[theme]` per-token overrides alone, so a live theme switch can re-compose them onto a new preset. */
51
+ themeOverrides: Partial<Theme>;
32
52
  /**
33
53
  * ui.reviewState / ui.reviewWidth are CLIENT VIEW STATE: the review panel's
34
54
  * collapse mode and expanded-rail width, persisted so they survive restarts.
35
55
  */
36
- ui: { autoClose: AutoClose; editor?: string; reviewState: ReviewPanelMode; reviewWidth: number };
56
+ ui: {
57
+ autoClose: AutoClose;
58
+ editor?: string;
59
+ reviewState: ReviewPanelMode;
60
+ reviewWidth: number;
61
+ /** The selected theme preset name; its tokens are the base for `theme`, before any `[theme]` overrides. */
62
+ theme: ThemeName;
63
+ };
37
64
  /** Planner-local author renames: identity id → display name ([authors] table). */
38
65
  authors: Record<string, string>;
66
+ /** Marker-popover quick actions ([[actions]] tables); the 5 defaults when unset. */
67
+ actions: QuickAction[];
39
68
  integrations: IntegrationsConfig;
40
69
  }
41
70
 
@@ -47,8 +76,9 @@ export const DEFAULT_KEYS: Record<string, string[]> = {
47
76
  bottom: ["G"],
48
77
  span: ["v"],
49
78
  comment: ["c"],
50
- suggest: ["s"],
51
79
  cut: ["x"],
80
+ reject_hunk: ["X"],
81
+ restore_curation: ["u"],
52
82
  edit: ["e"],
53
83
  next_annotation: ["n"],
54
84
  prev_annotation: ["p"],
@@ -68,14 +98,39 @@ function parseToml(text: string): Record<string, unknown> {
68
98
  return Bun.TOML.parse(text) as Record<string, unknown>;
69
99
  }
70
100
 
101
+ /**
102
+ * Parse a `[[actions]]` array-of-tables. A table without a non-empty `prompt`
103
+ * is skipped; any valid table REPLACES the defaults (the whole set is the
104
+ * user's, not merged). Returns undefined when nothing usable is present.
105
+ */
106
+ function parseActions(raw: unknown): QuickAction[] | undefined {
107
+ if (!Array.isArray(raw)) return undefined;
108
+ const actions: QuickAction[] = [];
109
+ for (const entry of raw) {
110
+ if (!entry || typeof entry !== "object") continue;
111
+ const table = entry as Record<string, unknown>;
112
+ const prompt = table["prompt"];
113
+ if (typeof prompt !== "string" || !prompt.trim()) continue;
114
+ const metadata = table["metadata"];
115
+ actions.push(
116
+ typeof metadata === "string" && metadata.trim() ? { prompt, metadata } : { prompt },
117
+ );
118
+ }
119
+ return actions.length ? actions : undefined;
120
+ }
121
+
71
122
  function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig {
72
123
  const out: CueloopConfig = {
73
124
  keys: { ...base.keys },
74
125
  theme: { ...base.theme },
126
+ themeOverrides: { ...base.themeOverrides },
75
127
  ui: { ...base.ui },
76
128
  authors: { ...base.authors },
129
+ actions: [...base.actions],
77
130
  integrations: { obsidian: { ...base.integrations.obsidian } },
78
131
  };
132
+ const actions = parseActions(raw["actions"]);
133
+ if (actions) out.actions = actions;
79
134
  const authors = raw["authors"] as Record<string, unknown> | undefined;
80
135
  if (authors) {
81
136
  for (const [id, value] of Object.entries(authors)) {
@@ -93,60 +148,98 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
93
148
  | undefined;
94
149
  if (ui && ui.auto_close !== undefined) {
95
150
  if (ui.auto_close === "off") out.ui.autoClose = "off";
96
- else if (typeof ui.auto_close === "number" && ui.auto_close >= 0) out.ui.autoClose = ui.auto_close;
151
+ else if (typeof ui.auto_close === "number" && ui.auto_close >= 0)
152
+ out.ui.autoClose = ui.auto_close;
97
153
  }
98
154
  if (ui && typeof ui.editor === "string" && ui.editor.trim()) out.ui.editor = ui.editor.trim();
99
155
  if (ui && typeof ui.review_width === "number" && Number.isFinite(ui.review_width)) {
100
156
  out.ui.reviewWidth = clampWidth(ui.review_width);
101
157
  }
102
- if (ui && (ui.review_state === "expanded" || ui.review_state === "compact" || ui.review_state === "hidden")) {
158
+ if (
159
+ ui &&
160
+ (ui.review_state === "expanded" ||
161
+ ui.review_state === "compact" ||
162
+ ui.review_state === "hidden")
163
+ ) {
103
164
  out.ui.reviewState = ui.review_state;
104
165
  }
105
- const theme = raw["theme"] as Partial<Record<keyof Theme, string>> | undefined;
106
- if (theme) {
107
- for (const [token, value] of Object.entries(theme)) {
108
- if (token in out.theme && typeof value === "string") {
109
- (out.theme as unknown as Record<string, string>)[token] = value;
110
- }
111
- }
112
- }
113
166
  const integrations = raw["integrations"] as Record<string, unknown> | undefined;
114
167
  const obsidian = integrations?.["obsidian"] as Record<string, unknown> | undefined;
115
168
  if (obsidian) {
116
169
  const obsidianConfig = out.integrations.obsidian;
117
170
  if (typeof obsidian["vault"] === "string") obsidianConfig.vault = obsidian["vault"];
118
171
  if (typeof obsidian["folder"] === "string") obsidianConfig.folder = obsidian["folder"];
119
- if (typeof obsidian["filenameFormat"] === "string") obsidianConfig.filenameFormat = obsidian["filenameFormat"];
172
+ if (typeof obsidian["filenameFormat"] === "string")
173
+ obsidianConfig.filenameFormat = obsidian["filenameFormat"];
120
174
  const separator = obsidian["separator"];
121
- if (separator === "space" || separator === "dash" || separator === "underscore") obsidianConfig.separator = separator;
175
+ if (separator === "space" || separator === "dash" || separator === "underscore")
176
+ obsidianConfig.separator = separator;
122
177
  const exportOn = obsidian["exportOn"];
123
- if (exportOn === "approve" || exportOn === "resolve" || exportOn === "manual") obsidianConfig.exportOn = exportOn;
178
+ if (exportOn === "approve" || exportOn === "resolve" || exportOn === "manual")
179
+ obsidianConfig.exportOn = exportOn;
124
180
  }
125
181
  return out;
126
182
  }
127
183
 
128
- export function loadConfig(options: { repoRoot?: string; userConfigPath?: string } = {}): CueloopConfig {
184
+ export function loadConfig(
185
+ options: { repoRoot?: string; userConfigPath?: string } = {},
186
+ ): CueloopConfig {
129
187
  let config: CueloopConfig = {
130
188
  keys: { ...DEFAULT_KEYS },
131
189
  theme: { ...DARK },
132
- ui: { autoClose: "off", reviewState: "expanded", reviewWidth: REVIEW_DEFAULT_WIDTH },
190
+ themeOverrides: {},
191
+ ui: {
192
+ autoClose: "off",
193
+ reviewState: "expanded",
194
+ reviewWidth: REVIEW_DEFAULT_WIDTH,
195
+ theme: DEFAULT_THEME_NAME,
196
+ },
133
197
  authors: {},
198
+ actions: [...DEFAULT_QUICK_ACTIONS],
134
199
  integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
135
200
  };
201
+ // Theme name and per-token overrides are separate concerns, composed once
202
+ // after all layers: the last file to set [ui] theme wins, and every [theme]
203
+ // override from every file lands on top - so a later preset never discards an
204
+ // earlier file's token overrides.
205
+ let themeName = DEFAULT_THEME_NAME;
206
+ const themeOverrides: Partial<Record<keyof Theme, string>> = {};
136
207
  const userPath = userConfigPathFrom(options.userConfigPath);
137
- for (const path of [userPath, options.repoRoot ? join(options.repoRoot, ".cueloop", "config.toml") : undefined]) {
208
+ for (const path of [
209
+ userPath,
210
+ options.repoRoot ? join(options.repoRoot, ".cueloop", "config.toml") : undefined,
211
+ ]) {
138
212
  if (!path || !existsSync(path)) continue;
139
213
  try {
140
- config = layer(config, parseToml(readFileSync(path, "utf8")));
214
+ const raw = parseToml(readFileSync(path, "utf8"));
215
+ config = layer(config, raw);
216
+ const rawTheme = (raw["ui"] as { theme?: unknown } | undefined)?.theme;
217
+ if (typeof rawTheme === "string" && isThemeName(rawTheme)) themeName = rawTheme;
218
+ collectThemeOverrides(raw["theme"], themeOverrides);
141
219
  } catch {
142
220
  // a broken config never blocks a review; defaults win
143
221
  }
144
222
  }
223
+ config.ui.theme = themeName;
224
+ config.themeOverrides = themeOverrides;
225
+ config.theme = { ...themeForName(themeName), ...themeOverrides };
145
226
  return config;
146
227
  }
147
228
 
229
+ /** Merge a raw `[theme]` table's known string tokens into the accumulated overrides. */
230
+ function collectThemeOverrides(raw: unknown, into: Partial<Record<keyof Theme, string>>): void {
231
+ if (!raw || typeof raw !== "object") return;
232
+ for (const [token, value] of Object.entries(raw)) {
233
+ if (token in DARK && typeof value === "string") into[token as keyof Theme] = value;
234
+ }
235
+ }
236
+
148
237
  /** Reverse lookup: key name (+shift) → action, per the loaded keymap. */
149
- export function actionFor(keys: Record<string, string[]>, name: string, shift: boolean): string | undefined {
238
+ export function actionFor(
239
+ keys: Record<string, string[]>,
240
+ name: string,
241
+ shift: boolean,
242
+ ): string | undefined {
150
243
  const wanted = shift && name.length === 1 ? name.toUpperCase() : name;
151
244
  for (const [action, combos] of Object.entries(keys)) {
152
245
  if (combos.includes(wanted)) return action;
@@ -199,6 +292,11 @@ export function persistReviewState(state: ReviewPanelMode, userConfigPath?: stri
199
292
  persistUiSetting("review_state", `"${state}"`, userConfigPath);
200
293
  }
201
294
 
295
+ /** Persist the selected theme preset (`[ui] theme`) into the user config. */
296
+ export function persistTheme(name: ThemeName, userConfigPath?: string): void {
297
+ persistUiSetting("theme", `"${name}"`, userConfigPath);
298
+ }
299
+
202
300
  function escapeRegExp(text: string): string {
203
301
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
204
302
  }