@cueloop/client 0.1.0-alpha.3 → 0.1.0-alpha.31

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 (105) hide show
  1. package/package.json +17 -11
  2. package/src/App.autoclose.test.tsx +133 -0
  3. package/src/App.diff.test.tsx +27 -20
  4. package/src/App.export.test.tsx +19 -28
  5. package/src/App.plan-review.test.tsx +507 -0
  6. package/src/App.readonly.test.tsx +26 -27
  7. package/src/App.rendering.test.tsx +119 -0
  8. package/src/App.review-panel.test.tsx +123 -0
  9. package/src/App.submit-confirm.test.tsx +216 -0
  10. package/src/App.test.tsx +83 -64
  11. package/src/App.tsx +0 -0
  12. package/src/App.walk.test.tsx +265 -0
  13. package/src/attribution.test.ts +31 -0
  14. package/src/attribution.ts +32 -0
  15. package/src/clipboard.test.ts +23 -0
  16. package/src/clipboard.ts +26 -0
  17. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  18. package/src/components/AnnotationCard.stories.tsx +75 -0
  19. package/src/components/AnnotationCard.tsx +208 -0
  20. package/src/components/Breadcrumb.stories.tsx +19 -0
  21. package/src/components/Breadcrumb.tsx +38 -0
  22. package/src/components/CodeBlock.stories.tsx +25 -0
  23. package/src/components/CodeBlock.tsx +68 -0
  24. package/src/components/CompletionOverlay.stories.tsx +23 -0
  25. package/src/components/CompletionOverlay.tsx +49 -0
  26. package/src/components/ComposeBar.stories.tsx +18 -0
  27. package/src/components/ComposeBar.tsx +54 -0
  28. package/src/components/ConfirmCard.stories.tsx +37 -0
  29. package/src/components/ConfirmCard.tsx +117 -0
  30. package/src/components/ConfirmDialog.stories.tsx +21 -0
  31. package/src/components/ConfirmDialog.tsx +56 -0
  32. package/src/components/DiffSheet.stories.tsx +44 -0
  33. package/src/components/DiffSheet.tsx +243 -0
  34. package/src/components/InboxList.stories.tsx +18 -0
  35. package/src/components/InboxList.tsx +55 -0
  36. package/src/components/PlanSheet.stories.tsx +81 -0
  37. package/src/components/PlanSheet.tsx +329 -0
  38. package/src/components/PromptDialog.stories.tsx +36 -0
  39. package/src/components/PromptDialog.tsx +62 -0
  40. package/src/components/ReviewPanel.stories.tsx +87 -0
  41. package/src/components/ReviewPanel.tsx +116 -0
  42. package/src/components/ReviewRail.stories.tsx +107 -0
  43. package/src/components/ReviewRail.tsx +180 -0
  44. package/src/components/SettingsDialog.stories.tsx +63 -0
  45. package/src/components/SettingsDialog.tsx +132 -0
  46. package/src/components/SettingsRows.stories.tsx +18 -0
  47. package/src/components/SettingsRows.tsx +92 -0
  48. package/src/components/WalkWizard.stories.tsx +89 -0
  49. package/src/components/WalkWizard.tsx +146 -0
  50. package/src/components/__snapshots__/stories.test.tsx.snap +1519 -0
  51. package/src/components/primitives/Button.stories.tsx +32 -0
  52. package/src/components/primitives/Button.tsx +44 -0
  53. package/src/components/primitives/Card.stories.tsx +25 -0
  54. package/src/components/primitives/Card.tsx +59 -0
  55. package/src/components/primitives/Dialog.stories.tsx +24 -0
  56. package/src/components/primitives/Dialog.tsx +54 -0
  57. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  58. package/src/components/primitives/StatusBar.tsx +22 -0
  59. package/src/components/primitives/Tabs.stories.tsx +29 -0
  60. package/src/components/primitives/Tabs.tsx +97 -0
  61. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  62. package/src/components/primitives/Toolbar.tsx +14 -0
  63. package/src/components/primitives/frame.test.ts +12 -0
  64. package/src/components/primitives/frame.ts +14 -0
  65. package/src/components/stories-app.tsx +95 -0
  66. package/src/components/stories.test.tsx +60 -0
  67. package/src/components/story-fixtures.ts +90 -0
  68. package/src/components/story.ts +66 -0
  69. package/src/components/syntax-highlight.ts +57 -0
  70. package/src/components/theme-context.stories.tsx +41 -0
  71. package/src/components/theme-context.tsx +30 -0
  72. package/src/components/truncate-text.ts +6 -0
  73. package/src/config.test.ts +196 -30
  74. package/src/config.ts +128 -16
  75. package/src/editor.test.ts +134 -0
  76. package/src/editor.ts +101 -13
  77. package/src/herdr.test.ts +41 -0
  78. package/src/herdr.ts +21 -0
  79. package/src/index.ts +2 -0
  80. package/src/integrations.test.ts +41 -0
  81. package/src/integrations.ts +28 -0
  82. package/src/intent-dispatch.test.ts +308 -0
  83. package/src/intent-dispatch.ts +295 -0
  84. package/src/key-bindings.test.ts +142 -0
  85. package/src/key-bindings.ts +363 -0
  86. package/src/keymap.test.ts +477 -0
  87. package/src/keymap.ts +272 -0
  88. package/src/review-panel.test.ts +111 -0
  89. package/src/review-panel.ts +76 -0
  90. package/src/run.ts +9 -5
  91. package/src/serve.test.ts +13 -6
  92. package/src/serve.ts +7 -7
  93. package/src/session-controller.share.test.ts +248 -0
  94. package/src/session-controller.ts +607 -0
  95. package/src/share.ts +80 -0
  96. package/src/test-support.ts +87 -0
  97. package/src/theme.ts +26 -3
  98. package/src/view-diff.test.ts +19 -8
  99. package/src/view-diff.ts +22 -22
  100. package/src/view-plan.test.ts +260 -0
  101. package/src/view-plan.ts +365 -0
  102. package/src/walk.test.ts +116 -0
  103. package/src/walk.ts +65 -0
  104. package/src/view.test.ts +0 -157
  105. package/src/view.ts +0 -0
@@ -0,0 +1,90 @@
1
+ /** Shared fixture data for the stories catalog: one plan session, one diff. */
2
+
3
+ import type { Annotation, ReviewSession } from "@cueloop/schema";
4
+ import { buildDisplay, marksByDisplay } from "../view-plan";
5
+ import { diffRows } from "../view-diff";
6
+
7
+ export const FIXTURE_PLAN = `# Migration Plan
8
+
9
+ ## Context
10
+
11
+ The daemon persists sessions to disk atomically.
12
+
13
+ ## Steps
14
+
15
+ - move the store
16
+ - add recovery
17
+
18
+ \`\`\`ts
19
+ export function persist(session: Session): void {
20
+ // one JSON document per session
21
+ writeAtomically(pathFor(session.id), serialize(session));
22
+ }
23
+ \`\`\`
24
+ `;
25
+
26
+ export const FIXTURE_PATCH = `diff --git a/src/store.ts b/src/store.ts
27
+ index 111..222 100644
28
+ --- a/src/store.ts
29
+ +++ b/src/store.ts
30
+ @@ -1,4 +1,4 @@
31
+ export class Store {
32
+ - private items = [];
33
+ + private items = new Map();
34
+ }
35
+ `;
36
+
37
+ export const FIXTURE_ANNOTATIONS: Annotation[] = [
38
+ {
39
+ id: "a_story_1",
40
+ kind: "comment",
41
+ anchor: { quote: "persists sessions", prefix: "The daemon ", suffix: " to disk" },
42
+ body: "Which daemon owns this?",
43
+ createdAt: "2026-01-01T00:00:00Z",
44
+ },
45
+ {
46
+ id: "a_story_2",
47
+ kind: "suggestion",
48
+ anchor: { quote: "move the store", prefix: "", suffix: "" },
49
+ body: "move the store behind one interface",
50
+ createdAt: "2026-01-01T00:00:01Z",
51
+ },
52
+ ];
53
+
54
+ export function fixturePlanSession(overrides: Partial<ReviewSession> = {}): ReviewSession {
55
+ return {
56
+ schemaVersion: "1",
57
+ id: "s_story_plan",
58
+ workspace: { repoRoot: "/repo", branch: "main" },
59
+ artifact: { type: "plan", content: FIXTURE_PLAN, meta: { title: "Migration Plan", agent: "agent/worker-3" } },
60
+ revisions: [{ revision: 1, content: FIXTURE_PLAN, submittedAt: "2026-01-01T00:00:00Z" }],
61
+ annotations: FIXTURE_ANNOTATIONS,
62
+ status: "pending",
63
+ ...overrides,
64
+ } as ReviewSession;
65
+ }
66
+
67
+ export function fixtureDiffSession(overrides: Partial<ReviewSession> = {}): ReviewSession {
68
+ return {
69
+ schemaVersion: "1",
70
+ id: "s_story_diff",
71
+ workspace: { repoRoot: "/repo", branch: "main" },
72
+ artifact: { type: "diff", content: FIXTURE_PATCH, meta: { title: "working tree", agent: "agent/worker-3" } },
73
+ revisions: [{ revision: 1, content: FIXTURE_PATCH, submittedAt: "2026-01-01T00:00:00Z" }],
74
+ annotations: [],
75
+ status: "pending",
76
+ ...overrides,
77
+ } as ReviewSession;
78
+ }
79
+
80
+ export function fixtureDisplay() {
81
+ return buildDisplay(FIXTURE_PLAN);
82
+ }
83
+
84
+ export function fixtureMarks() {
85
+ return marksByDisplay(FIXTURE_ANNOTATIONS, fixtureDisplay());
86
+ }
87
+
88
+ export function fixtureDiffRows() {
89
+ return diffRows(FIXTURE_PATCH);
90
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The story contract. Every component file in components/ ships a
3
+ * colocated *.stories.tsx that exports `meta` plus named Story objects.
4
+ * The catalog IS the regression suite: stories.test.tsx walks every story,
5
+ * snapshots its char frame, and asserts declared colors against the styled
6
+ * spans; the `bun run stories` TUI renders the same objects interactively.
7
+ */
8
+
9
+ import type React from "react";
10
+
11
+ export interface StoryMeta {
12
+ /** Catalog path, e.g. "primitives/Card". */
13
+ title: string;
14
+ }
15
+
16
+ export interface Story {
17
+ render: () => React.ReactNode;
18
+ /**
19
+ * Colors (hex tokens) that must appear among the rendered frame's styled
20
+ * spans - the color regression net for color-bearing stories.
21
+ */
22
+ expectedColors?: string[];
23
+ /** Virtual terminal size; defaults to 80x24. */
24
+ size?: { width: number; height: number };
25
+ }
26
+
27
+ export interface LoadedStory {
28
+ moduleTitle: string;
29
+ storyName: string;
30
+ story: Story;
31
+ }
32
+
33
+ export function isStory(value: unknown): value is Story {
34
+ return typeof value === "object" && value !== null && typeof (value as Story).render === "function";
35
+ }
36
+
37
+ /** Import every *.stories.tsx next to the components and flatten the exports. */
38
+ export async function loadStories(): Promise<LoadedStory[]> {
39
+ const glob = new Bun.Glob("**/*.stories.tsx");
40
+ const files = [...glob.scanSync({ cwd: import.meta.dir })].sort();
41
+ const loaded: LoadedStory[] = [];
42
+ for (const file of files) {
43
+ const moduleExports = (await import(`${import.meta.dir}/${file}`)) as Record<string, unknown>;
44
+ const meta = moduleExports["meta"] as StoryMeta | undefined;
45
+ const moduleTitle = meta?.title ?? file.replace(/\.stories\.tsx$/, "");
46
+ for (const [exportName, exported] of Object.entries(moduleExports)) {
47
+ if (exportName === "meta" || !isStory(exported)) continue;
48
+ loaded.push({ moduleTitle, storyName: exportName, story: exported });
49
+ }
50
+ }
51
+ return loaded;
52
+ }
53
+
54
+ /** Component files that must carry stories: every .tsx that is not a story/test. */
55
+ export function componentFilesMissingStories(): string[] {
56
+ const componentGlob = new Bun.Glob("**/*.tsx");
57
+ const files = [...componentGlob.scanSync({ cwd: import.meta.dir })];
58
+ const missing: string[] = [];
59
+ for (const file of files) {
60
+ if (file.endsWith(".stories.tsx") || file.endsWith(".test.tsx")) continue;
61
+ if (file === "stories-app.tsx") continue;
62
+ const storiesSibling = file.replace(/\.tsx$/, ".stories.tsx");
63
+ if (!files.includes(storiesSibling)) missing.push(file);
64
+ }
65
+ return missing;
66
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Code highlighting tokens: the theme's named tokens projected onto
3
+ * tree-sitter capture names for the native code renderable. One SyntaxStyle
4
+ * per theme object, cached, so every code block on screen shares a handle.
5
+ * Unknown languages simply render unstyled - highlighting is an enhancement,
6
+ * never a blocker.
7
+ */
8
+
9
+ import { SyntaxStyle, infoStringToFiletype } from "@opentui/core";
10
+ import type { Theme } from "../theme";
11
+
12
+ /** Markdown fence info -> tree-sitter filetype, with the aliases we accept. */
13
+ export function filetypeFor(language?: string): string | undefined {
14
+ const info = (language ?? "").toLowerCase();
15
+ if (!info) return undefined;
16
+ const aliases: Record<string, string> = {
17
+ ts: "typescript",
18
+ js: "javascript",
19
+ py: "python",
20
+ rb: "ruby",
21
+ rs: "rust",
22
+ sh: "bash",
23
+ shell: "bash",
24
+ zsh: "bash",
25
+ yml: "yaml",
26
+ };
27
+ return infoStringToFiletype(aliases[info] ?? info) ?? aliases[info] ?? info;
28
+ }
29
+
30
+ const syntaxStyleCache = new WeakMap<Theme, SyntaxStyle>();
31
+
32
+ export function syntaxStyleFor(theme: Theme): SyntaxStyle {
33
+ const cached = syntaxStyleCache.get(theme);
34
+ if (cached) return cached;
35
+ const style = SyntaxStyle.fromStyles({
36
+ default: { fg: theme.textMuted },
37
+ comment: { fg: theme.textDim, italic: true },
38
+ string: { fg: theme.green },
39
+ number: { fg: theme.blue },
40
+ constant: { fg: theme.blue },
41
+ boolean: { fg: theme.blue },
42
+ keyword: { fg: theme.accent },
43
+ operator: { fg: theme.textDim },
44
+ punctuation: { fg: theme.textDim },
45
+ function: { fg: theme.text },
46
+ method: { fg: theme.text },
47
+ type: { fg: theme.blue },
48
+ constructor: { fg: theme.blue },
49
+ variable: { fg: theme.textMuted },
50
+ property: { fg: theme.textMuted },
51
+ parameter: { fg: theme.textMuted },
52
+ tag: { fg: theme.accent },
53
+ attribute: { fg: theme.blue },
54
+ });
55
+ syntaxStyleCache.set(theme, style);
56
+ return style;
57
+ }
@@ -0,0 +1,41 @@
1
+ import React from "react";
2
+ import { DARK, type Theme } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { ThemeProvider } from "./theme-context";
5
+ import { Card } from "./primitives/Card";
6
+
7
+ export const meta: StoryMeta = { title: "theme-context" };
8
+
9
+ /** A high-contrast variant to prove tokens flow through the provider. */
10
+ const HIGH_CONTRAST: Theme = { ...DARK, accent: "#00ffff", elevated: "#000000", border: "#ffffff" };
11
+
12
+ export const ContextDefault: Story = {
13
+ render: () => (
14
+ <Card title=" default dark theme " contentRows={1}>
15
+ <text>no provider needed</text>
16
+ </Card>
17
+ ),
18
+ expectedColors: [DARK.elevated],
19
+ };
20
+
21
+ export const ProviderSwap: Story = {
22
+ render: () => (
23
+ <ThemeProvider theme={HIGH_CONTRAST}>
24
+ <Card title=" provider theme " contentRows={1} borderColor={HIGH_CONTRAST.accent}>
25
+ <text>tokens come from the provider</text>
26
+ </Card>
27
+ </ThemeProvider>
28
+ ),
29
+ expectedColors: ["#00ffff", "#000000"],
30
+ };
31
+
32
+ export const PropOverride: Story = {
33
+ render: () => (
34
+ <ThemeProvider theme={DARK}>
35
+ <Card title=" prop-overridden theme " contentRows={1} theme={HIGH_CONTRAST} borderColor={HIGH_CONTRAST.accent}>
36
+ <text>the theme prop beats the context</text>
37
+ </Card>
38
+ </ThemeProvider>
39
+ ),
40
+ expectedColors: ["#00ffff"],
41
+ };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Provider theming for the component system. The context default is the
3
+ * built-in dark theme, so embedding a component (or rendering a story) needs
4
+ * no provider at all. One ThemeProvider swap switches the live theme; every
5
+ * component also accepts an optional `theme` prop that overrides the context
6
+ * for explicit control. Components consume named tokens only - a hardcoded
7
+ * color inside components/ is a review rejection.
8
+ */
9
+
10
+ import React, { createContext, useContext } from "react";
11
+ import { DARK, type Theme } from "../theme";
12
+
13
+ const ThemeContext = createContext<Theme>(DARK);
14
+
15
+ export function ThemeProvider({ theme, children }: { theme: Theme; children: React.ReactNode }): React.ReactNode {
16
+ return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
17
+ }
18
+
19
+ export function useTheme(): Theme {
20
+ return useContext(ThemeContext);
21
+ }
22
+
23
+ /**
24
+ * Token resolution for a component: the explicit `theme` prop wins over the
25
+ * provider context, which defaults to the built-in dark theme.
26
+ */
27
+ export function useComponentTheme(override?: Theme): Theme {
28
+ const contextTheme = useContext(ThemeContext);
29
+ return override ?? contextTheme;
30
+ }
@@ -0,0 +1,6 @@
1
+ /** Truncate text to one line of at most maxLength characters, ellipsis-terminated. */
2
+
3
+ export function truncateToSingleLine(value: string, maxLength: number): string {
4
+ const oneLine = value.replace(/\n/g, " ");
5
+ return oneLine.length > maxLength ? oneLine.slice(0, maxLength - 1) + "…" : oneLine;
6
+ }
@@ -2,57 +2,180 @@ 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 { DEFAULT_KEYS, actionFor, loadConfig, persistAuthorName, persistReviewState, persistReviewWidth } from "./config";
6
+ import { REVIEW_DEFAULT_WIDTH, REVIEW_MAX_WIDTH } from "./review-panel";
6
7
  import { DARK } from "./theme";
7
8
 
8
9
  describe("loadConfig", () => {
9
10
  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);
11
+ // Act
12
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
13
+
14
+ // Assert
15
+ expect(config.keys["comment"]).toEqual(["c"]);
16
+ expect(config.theme.accent).toBe(DARK.accent);
13
17
  });
14
18
 
15
19
  test("user config rebinds actions and overrides theme tokens", () => {
20
+ // Arrange
16
21
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-"));
17
22
  const path = join(dir, "config.toml");
18
23
  writeFileSync(
19
24
  path,
20
25
  `[keys]\ncomment = "a"\nsubmit = ["return", "S"]\n\n[theme]\naccent = "#ff0000"\n`,
21
26
  );
27
+
22
28
  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);
29
+ // Act
30
+ const config = loadConfig({ userConfigPath: path });
31
+
32
+ // Assert
33
+ expect(config.keys["comment"]).toEqual(["a"]);
34
+ expect(config.keys["submit"]).toEqual(["return", "S"]);
35
+ expect(config.keys["cut"]).toEqual(["x"]); // untouched defaults survive
36
+ expect(config.theme.accent).toBe("#ff0000");
37
+ expect(config.theme.green).toBe(DARK.green);
29
38
  } finally {
30
39
  rmSync(dir, { recursive: true, force: true });
31
40
  }
32
41
  });
33
42
 
34
43
  test("repo config layers over user config", () => {
44
+ // Arrange
35
45
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg2-"));
36
46
  const user = join(dir, "user.toml");
37
47
  const repoRoot = join(dir, "repo");
38
48
  writeFileSync(user, `[keys]\ncomment = "a"\n`);
39
49
  Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
40
50
  writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[keys]\ncomment = "z"\n`);
51
+
41
52
  try {
42
- const c = loadConfig({ userConfigPath: user, repoRoot });
43
- expect(c.keys["comment"]).toEqual(["z"]);
53
+ // Act
54
+ const config = loadConfig({ userConfigPath: user, repoRoot });
55
+
56
+ // Assert
57
+ expect(config.keys["comment"]).toEqual(["z"]);
44
58
  } finally {
45
59
  rmSync(dir, { recursive: true, force: true });
46
60
  }
47
61
  });
48
62
 
49
63
  test("broken config never blocks a review", () => {
64
+ // Arrange
50
65
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg3-"));
51
66
  const path = join(dir, "config.toml");
52
67
  writeFileSync(path, "not [valid toml");
68
+
69
+ try {
70
+ // Act
71
+ const config = loadConfig({ userConfigPath: path });
72
+
73
+ // Assert
74
+ expect(config.keys["comment"]).toEqual(["c"]);
75
+ } finally {
76
+ rmSync(dir, { recursive: true, force: true });
77
+ }
78
+ });
79
+
80
+ test("[ui] parses auto_close and the editor override", () => {
81
+ // Arrange
82
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg4-"));
83
+ const path = join(dir, "config.toml");
84
+ writeFileSync(path, `[ui]\nauto_close = 3\neditor = "code --wait"\n`);
85
+
86
+ try {
87
+ // Act
88
+ const config = loadConfig({ userConfigPath: path });
89
+
90
+ // Assert
91
+ expect(config.ui.autoClose).toBe(3);
92
+ expect(config.ui.editor).toBe("code --wait");
93
+ } finally {
94
+ rmSync(dir, { recursive: true, force: true });
95
+ }
96
+ });
97
+
98
+ test("[ui] editor defaults to undefined and ignores a blank value", () => {
99
+ // Arrange
100
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg5-"));
101
+ const path = join(dir, "config.toml");
102
+ writeFileSync(path, `[ui]\neditor = " "\n`);
103
+
104
+ try {
105
+ // Assert
106
+ expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.editor).toBeUndefined();
107
+ expect(loadConfig({ userConfigPath: path }).ui.editor).toBeUndefined();
108
+ } finally {
109
+ rmSync(dir, { recursive: true, force: true });
110
+ }
111
+ });
112
+
113
+ test("[ui] review panel defaults to an expanded rail at the default width", () => {
114
+ // Act
115
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
116
+
117
+ // Assert
118
+ expect(config.ui.reviewState).toBe("expanded");
119
+ expect(config.ui.reviewWidth).toBe(REVIEW_DEFAULT_WIDTH);
120
+ });
121
+
122
+ test("[ui] parses review_width (clamped) and review_state", () => {
123
+ // Arrange
124
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg6-"));
125
+ const path = join(dir, "config.toml");
126
+ writeFileSync(path, `[ui]\nreview_width = 999\nreview_state = "compact"\n`);
127
+
53
128
  try {
54
- const c = loadConfig({ userConfigPath: path });
55
- expect(c.keys["comment"]).toEqual(["c"]);
129
+ // Act
130
+ const config = loadConfig({ userConfigPath: path });
131
+
132
+ // Assert
133
+ expect(config.ui.reviewWidth).toBe(REVIEW_MAX_WIDTH); // out-of-range width clamps
134
+ expect(config.ui.reviewState).toBe("compact");
135
+ } finally {
136
+ rmSync(dir, { recursive: true, force: true });
137
+ }
138
+ });
139
+
140
+ test("[ui] ignores an unknown review_state and a non-numeric width", () => {
141
+ // Arrange
142
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg7-"));
143
+ const path = join(dir, "config.toml");
144
+ writeFileSync(path, `[ui]\nreview_width = "wide"\nreview_state = "sideways"\n`);
145
+
146
+ try {
147
+ // Act
148
+ const config = loadConfig({ userConfigPath: path });
149
+
150
+ // Assert
151
+ expect(config.ui.reviewWidth).toBe(REVIEW_DEFAULT_WIDTH);
152
+ expect(config.ui.reviewState).toBe("expanded");
153
+ } finally {
154
+ rmSync(dir, { recursive: true, force: true });
155
+ }
156
+ });
157
+
158
+ test("persistReviewWidth and persistReviewState round-trip through the config file", () => {
159
+ // Arrange
160
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg8-"));
161
+ const path = join(dir, "config.toml");
162
+
163
+ try {
164
+ // Act
165
+ persistReviewWidth(42, path);
166
+ persistReviewState("hidden", path);
167
+
168
+ // Assert
169
+ const config = loadConfig({ userConfigPath: path });
170
+ expect(config.ui.reviewWidth).toBe(42);
171
+ expect(config.ui.reviewState).toBe("hidden");
172
+
173
+ // a second write replaces the key in place rather than appending a duplicate
174
+ // Act
175
+ persistReviewWidth(30, path);
176
+
177
+ // Assert
178
+ expect(loadConfig({ userConfigPath: path }).ui.reviewWidth).toBe(30);
56
179
  } finally {
57
180
  rmSync(dir, { recursive: true, force: true });
58
181
  }
@@ -61,45 +184,58 @@ describe("loadConfig", () => {
61
184
 
62
185
  describe("integrations.obsidian config", () => {
63
186
  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");
187
+ // Act
188
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
189
+
190
+ // Assert
191
+ expect(config.integrations.obsidian.vault).toBeUndefined();
192
+ expect(config.integrations.obsidian.folder).toBe("cueloop");
193
+ expect(config.integrations.obsidian.filenameFormat).toBe("{YYYY}-{MM}-{DD} - {title}");
194
+ expect(config.integrations.obsidian.separator).toBe("space");
195
+ expect(config.integrations.obsidian.exportOn).toBe("manual");
70
196
  });
71
197
 
72
198
  test("user config sets the section; invalid enum values are ignored", () => {
199
+ // Arrange
73
200
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs-"));
74
201
  const path = join(dir, "config.toml");
75
202
  writeFileSync(
76
203
  path,
77
204
  `[integrations.obsidian]\nvault = "/notes/vault"\nfolder = "plans"\nexportOn = "approve"\nseparator = "comma"\n`,
78
205
  );
206
+
79
207
  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
208
+ // Act
209
+ const config = loadConfig({ userConfigPath: path });
210
+
211
+ // Assert
212
+ expect(config.integrations.obsidian.vault).toBe("/notes/vault");
213
+ expect(config.integrations.obsidian.folder).toBe("plans");
214
+ expect(config.integrations.obsidian.exportOn).toBe("approve");
215
+ expect(config.integrations.obsidian.separator).toBe("space"); // invalid value falls back
216
+ expect(config.keys["comment"]).toEqual(["c"]); // other sections untouched
86
217
  } finally {
87
218
  rmSync(dir, { recursive: true, force: true });
88
219
  }
89
220
  });
90
221
 
91
222
  test("repo config layers over user config for the section", () => {
223
+ // Arrange
92
224
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs2-"));
93
225
  const user = join(dir, "user.toml");
94
226
  const repoRoot = join(dir, "repo");
95
227
  writeFileSync(user, `[integrations.obsidian]\nvault = "/user/vault"\nexportOn = "resolve"\n`);
96
228
  Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
97
229
  writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[integrations.obsidian]\nfolder = "repo-plans"\n`);
230
+
98
231
  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
232
+ // Act
233
+ const config = loadConfig({ userConfigPath: user, repoRoot });
234
+
235
+ // Assert
236
+ expect(config.integrations.obsidian.vault).toBe("/user/vault"); // user layer survives
237
+ expect(config.integrations.obsidian.exportOn).toBe("resolve");
238
+ expect(config.integrations.obsidian.folder).toBe("repo-plans"); // repo layer wins
103
239
  } finally {
104
240
  rmSync(dir, { recursive: true, force: true });
105
241
  }
@@ -108,9 +244,39 @@ describe("integrations.obsidian config", () => {
108
244
 
109
245
  describe("actionFor", () => {
110
246
  test("resolves keys and shifted keys to actions", () => {
247
+ // Assert
111
248
  expect(actionFor(DEFAULT_KEYS, "j", false)).toBe("down");
112
249
  expect(actionFor(DEFAULT_KEYS, "g", true)).toBe("bottom"); // G
113
250
  expect(actionFor(DEFAULT_KEYS, "return", false)).toBe("submit");
114
251
  expect(actionFor(DEFAULT_KEYS, "zz", false)).toBeUndefined();
115
252
  });
116
253
  });
254
+
255
+ describe("[authors] rename map", () => {
256
+ test("loads quoted fingerprint keys", () => {
257
+ // Arrange
258
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-authors-"));
259
+ const path = join(dir, "config.toml");
260
+ writeFileSync(path, `[authors]\n"SHA256:abc+def/gh" = "Alex"\n`);
261
+
262
+ // Act
263
+ const config = loadConfig({ userConfigPath: path });
264
+
265
+ // Assert
266
+ expect(config.authors["SHA256:abc+def/gh"]).toBe("Alex");
267
+ rmSync(dir, { recursive: true, force: true });
268
+ });
269
+
270
+ test("persistAuthorName round-trips and updates a fingerprint id in place", () => {
271
+ // Arrange
272
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-authors2-"));
273
+ const path = join(dir, "config.toml");
274
+
275
+ // Act / Assert
276
+ persistAuthorName("SHA256:abc+def/gh", "Alex", path);
277
+ expect(loadConfig({ userConfigPath: path }).authors["SHA256:abc+def/gh"]).toBe("Alex");
278
+ persistAuthorName("SHA256:abc+def/gh", "Alexa", path);
279
+ expect(loadConfig({ userConfigPath: path }).authors["SHA256:abc+def/gh"]).toBe("Alexa");
280
+ rmSync(dir, { recursive: true, force: true });
281
+ });
282
+ });