@cueloop/client 0.1.0-alpha.33 → 0.1.0-alpha.35
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.
- package/package.json +27 -26
- package/src/App.autoclose.test.tsx +7 -4
- package/src/App.diff.test.tsx +130 -4
- package/src/App.export.test.tsx +22 -4
- package/src/App.plan-review.test.tsx +127 -27
- package/src/App.readonly.test.tsx +31 -8
- package/src/App.rendering.test.tsx +12 -3
- package/src/App.review-panel.test.tsx +11 -3
- package/src/App.submit-confirm.test.tsx +12 -7
- package/src/App.test.tsx +46 -9
- package/src/App.toast.test.tsx +10 -3
- package/src/App.tsx +400 -100
- package/src/App.walk.test.tsx +30 -10
- package/src/attribution.ts +8 -2
- package/src/clipboard.ts +5 -1
- package/src/components/AnnotationCard.stories.tsx +28 -9
- package/src/components/AnnotationCard.tsx +53 -38
- package/src/components/Breadcrumb.tsx +7 -1
- package/src/components/CodeBlock.stories.tsx +19 -2
- package/src/components/CodeBlock.tsx +24 -9
- package/src/components/CompletionOverlay.tsx +13 -3
- package/src/components/ConfirmCard.stories.tsx +22 -3
- package/src/components/ConfirmCard.tsx +22 -5
- package/src/components/ConfirmDialog.tsx +9 -1
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +18 -2
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +184 -98
- package/src/components/InboxList.stories.tsx +16 -3
- package/src/components/InboxList.tsx +17 -10
- package/src/components/KeybindsDialog.tsx +19 -3
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +5 -1
- package/src/components/MenuBar.tsx +18 -2
- package/src/components/PlanSheet.stories.tsx +34 -2
- package/src/components/PlanSheet.tsx +141 -32
- package/src/components/PromptDialog.tsx +18 -2
- package/src/components/ReviewPanel.stories.tsx +13 -3
- package/src/components/ReviewPanel.tsx +8 -4
- package/src/components/ReviewRail.stories.tsx +145 -72
- package/src/components/ReviewRail.tsx +177 -38
- package/src/components/SettingsDialog.stories.tsx +51 -3
- package/src/components/SettingsDialog.tsx +8 -3
- package/src/components/SettingsRows.tsx +19 -3
- package/src/components/Toast.tsx +17 -2
- package/src/components/WalkWizard.stories.tsx +13 -2
- package/src/components/WalkWizard.tsx +9 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +281 -83
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.tsx +5 -1
- package/src/components/primitives/Dialog.tsx +9 -1
- package/src/components/primitives/Tabs.tsx +24 -4
- package/src/components/stories-app.tsx +34 -6
- package/src/components/story-fixtures.ts +11 -3
- package/src/components/story.ts +3 -1
- package/src/components/syntax-highlight.ts +47 -12
- package/src/components/theme-context.stories.tsx +6 -1
- package/src/components/theme-context.tsx +7 -1
- package/src/config.test.ts +178 -2
- package/src/config.ts +121 -23
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +13 -4
- package/src/editor.ts +31 -5
- package/src/herdr.ts +8 -2
- package/src/index.ts +9 -1
- package/src/integrations.ts +3 -1
- package/src/intent-dispatch.test.ts +193 -9
- package/src/intent-dispatch.ts +151 -18
- package/src/key-bindings.test.ts +19 -9
- package/src/key-bindings.ts +36 -14
- package/src/keymap.test.ts +171 -26
- package/src/keymap.ts +83 -16
- package/src/review-panel.test.ts +3 -1
- package/src/review-panel.ts +9 -2
- package/src/serve.test.ts +11 -3
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +65 -17
- package/src/session-controller.ts +301 -26
- package/src/share.ts +41 -8
- package/src/test-support.ts +16 -6
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +1 -3
- package/src/view-diff.ts +15 -4
- package/src/view-plan.test.ts +40 -6
- package/src/view-plan.ts +71 -28
- package/src/walk.test.ts +7 -2
- package/src/walk.ts +7 -2
package/src/config.test.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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 +
|
|
5
|
-
* layout (review_width + review_state),
|
|
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: {
|
|
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)
|
|
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 (
|
|
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")
|
|
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")
|
|
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")
|
|
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(
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
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(
|
|
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
|
}
|