@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.
- package/package.json +17 -11
- package/src/App.autoclose.test.tsx +133 -0
- package/src/App.diff.test.tsx +27 -20
- package/src/App.export.test.tsx +19 -28
- package/src/App.plan-review.test.tsx +507 -0
- package/src/App.readonly.test.tsx +26 -27
- package/src/App.rendering.test.tsx +119 -0
- package/src/App.review-panel.test.tsx +123 -0
- package/src/App.submit-confirm.test.tsx +216 -0
- package/src/App.test.tsx +83 -64
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +265 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +32 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +75 -0
- package/src/components/AnnotationCard.tsx +208 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +38 -0
- package/src/components/CodeBlock.stories.tsx +25 -0
- package/src/components/CodeBlock.tsx +68 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +49 -0
- package/src/components/ComposeBar.stories.tsx +18 -0
- package/src/components/ComposeBar.tsx +54 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +117 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +56 -0
- package/src/components/DiffSheet.stories.tsx +44 -0
- package/src/components/DiffSheet.tsx +243 -0
- package/src/components/InboxList.stories.tsx +18 -0
- package/src/components/InboxList.tsx +55 -0
- package/src/components/PlanSheet.stories.tsx +81 -0
- package/src/components/PlanSheet.tsx +329 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +62 -0
- package/src/components/ReviewPanel.stories.tsx +87 -0
- package/src/components/ReviewPanel.tsx +116 -0
- package/src/components/ReviewRail.stories.tsx +107 -0
- package/src/components/ReviewRail.tsx +180 -0
- package/src/components/SettingsDialog.stories.tsx +63 -0
- package/src/components/SettingsDialog.tsx +132 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +92 -0
- package/src/components/WalkWizard.stories.tsx +89 -0
- package/src/components/WalkWizard.tsx +146 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1519 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +44 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +54 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +97 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/stories-app.tsx +95 -0
- package/src/components/stories.test.tsx +60 -0
- package/src/components/story-fixtures.ts +90 -0
- package/src/components/story.ts +66 -0
- package/src/components/syntax-highlight.ts +57 -0
- package/src/components/theme-context.stories.tsx +41 -0
- package/src/components/theme-context.tsx +30 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +196 -30
- package/src/config.ts +128 -16
- package/src/editor.test.ts +134 -0
- package/src/editor.ts +101 -13
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +21 -0
- package/src/index.ts +2 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +28 -0
- package/src/intent-dispatch.test.ts +308 -0
- package/src/intent-dispatch.ts +295 -0
- package/src/key-bindings.test.ts +142 -0
- package/src/key-bindings.ts +363 -0
- package/src/keymap.test.ts +477 -0
- package/src/keymap.ts +272 -0
- package/src/review-panel.test.ts +111 -0
- package/src/review-panel.ts +76 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +13 -6
- package/src/serve.ts +7 -7
- package/src/session-controller.share.test.ts +248 -0
- package/src/session-controller.ts +607 -0
- package/src/share.ts +80 -0
- package/src/test-support.ts +87 -0
- package/src/theme.ts +26 -3
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +22 -22
- package/src/view-plan.test.ts +260 -0
- package/src/view-plan.ts +365 -0
- package/src/walk.test.ts +116 -0
- package/src/walk.ts +65 -0
- package/src/view.test.ts +0 -157
- 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
|
+
}
|
package/src/config.test.ts
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
expect(
|
|
28
|
-
expect(
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
expect(
|
|
69
|
-
expect(
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
expect(
|
|
85
|
-
expect(
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
});
|