@cueloop/client 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +109 -0
- package/src/App.diff.test.tsx +5 -18
- package/src/App.export.test.tsx +8 -13
- package/src/App.plan-review.test.tsx +293 -0
- package/src/App.readonly.test.tsx +6 -21
- package/src/App.rendering.test.tsx +104 -0
- package/src/App.review-panel.test.tsx +97 -0
- package/src/App.submit-confirm.test.tsx +165 -0
- package/src/App.test.tsx +20 -58
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +208 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +75 -0
- package/src/components/AnnotationCard.tsx +189 -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/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 +42 -0
- package/src/components/PlanSheet.stories.tsx +81 -0
- package/src/components/PlanSheet.tsx +309 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +116 -0
- package/src/components/ReviewRail.stories.tsx +103 -0
- package/src/components/ReviewRail.tsx +171 -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 +1459 -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 +53 -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 +51 -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 +105 -30
- package/src/config.ts +91 -16
- package/src/editor.test.ts +110 -0
- package/src/editor.ts +101 -13
- package/src/herdr.test.ts +37 -0
- package/src/herdr.ts +21 -0
- package/src/integrations.test.ts +29 -0
- package/src/integrations.ts +28 -0
- package/src/key-bindings.test.ts +120 -0
- package/src/key-bindings.ts +353 -0
- package/src/keymap.test.ts +347 -0
- package/src/keymap.ts +229 -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 +6 -6
- package/src/serve.ts +7 -7
- package/src/session-controller.ts +512 -0
- package/src/test-support.ts +87 -0
- package/src/theme.ts +23 -1
- package/src/view-diff.test.ts +8 -8
- package/src/view-diff.ts +22 -22
- package/src/view-plan.test.ts +179 -0
- package/src/view-plan.ts +362 -0
- package/src/walk.test.ts +110 -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,14 +2,15 @@ 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, 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
|
|
11
|
-
expect(
|
|
12
|
-
expect(
|
|
11
|
+
const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
|
|
12
|
+
expect(config.keys["comment"]).toEqual(["c"]);
|
|
13
|
+
expect(config.theme.accent).toBe(DARK.accent);
|
|
13
14
|
});
|
|
14
15
|
|
|
15
16
|
test("user config rebinds actions and overrides theme tokens", () => {
|
|
@@ -20,12 +21,12 @@ describe("loadConfig", () => {
|
|
|
20
21
|
`[keys]\ncomment = "a"\nsubmit = ["return", "S"]\n\n[theme]\naccent = "#ff0000"\n`,
|
|
21
22
|
);
|
|
22
23
|
try {
|
|
23
|
-
const
|
|
24
|
-
expect(
|
|
25
|
-
expect(
|
|
26
|
-
expect(
|
|
27
|
-
expect(
|
|
28
|
-
expect(
|
|
24
|
+
const config = loadConfig({ userConfigPath: path });
|
|
25
|
+
expect(config.keys["comment"]).toEqual(["a"]);
|
|
26
|
+
expect(config.keys["submit"]).toEqual(["return", "S"]);
|
|
27
|
+
expect(config.keys["cut"]).toEqual(["x"]); // untouched defaults survive
|
|
28
|
+
expect(config.theme.accent).toBe("#ff0000");
|
|
29
|
+
expect(config.theme.green).toBe(DARK.green);
|
|
29
30
|
} finally {
|
|
30
31
|
rmSync(dir, { recursive: true, force: true });
|
|
31
32
|
}
|
|
@@ -39,8 +40,8 @@ describe("loadConfig", () => {
|
|
|
39
40
|
Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
|
|
40
41
|
writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[keys]\ncomment = "z"\n`);
|
|
41
42
|
try {
|
|
42
|
-
const
|
|
43
|
-
expect(
|
|
43
|
+
const config = loadConfig({ userConfigPath: user, repoRoot });
|
|
44
|
+
expect(config.keys["comment"]).toEqual(["z"]);
|
|
44
45
|
} finally {
|
|
45
46
|
rmSync(dir, { recursive: true, force: true });
|
|
46
47
|
}
|
|
@@ -51,8 +52,82 @@ describe("loadConfig", () => {
|
|
|
51
52
|
const path = join(dir, "config.toml");
|
|
52
53
|
writeFileSync(path, "not [valid toml");
|
|
53
54
|
try {
|
|
54
|
-
const
|
|
55
|
-
expect(
|
|
55
|
+
const config = loadConfig({ userConfigPath: path });
|
|
56
|
+
expect(config.keys["comment"]).toEqual(["c"]);
|
|
57
|
+
} finally {
|
|
58
|
+
rmSync(dir, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("[ui] parses auto_close and the editor override", () => {
|
|
63
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg4-"));
|
|
64
|
+
const path = join(dir, "config.toml");
|
|
65
|
+
writeFileSync(path, `[ui]\nauto_close = 3\neditor = "code --wait"\n`);
|
|
66
|
+
try {
|
|
67
|
+
const config = loadConfig({ userConfigPath: path });
|
|
68
|
+
expect(config.ui.autoClose).toBe(3);
|
|
69
|
+
expect(config.ui.editor).toBe("code --wait");
|
|
70
|
+
} finally {
|
|
71
|
+
rmSync(dir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("[ui] editor defaults to undefined and ignores a blank value", () => {
|
|
76
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg5-"));
|
|
77
|
+
const path = join(dir, "config.toml");
|
|
78
|
+
writeFileSync(path, `[ui]\neditor = " "\n`);
|
|
79
|
+
try {
|
|
80
|
+
expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.editor).toBeUndefined();
|
|
81
|
+
expect(loadConfig({ userConfigPath: path }).ui.editor).toBeUndefined();
|
|
82
|
+
} finally {
|
|
83
|
+
rmSync(dir, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("[ui] review panel defaults to an expanded rail at the default width", () => {
|
|
88
|
+
const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
|
|
89
|
+
expect(config.ui.reviewState).toBe("expanded");
|
|
90
|
+
expect(config.ui.reviewWidth).toBe(REVIEW_DEFAULT_WIDTH);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("[ui] parses review_width (clamped) and review_state", () => {
|
|
94
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg6-"));
|
|
95
|
+
const path = join(dir, "config.toml");
|
|
96
|
+
writeFileSync(path, `[ui]\nreview_width = 999\nreview_state = "compact"\n`);
|
|
97
|
+
try {
|
|
98
|
+
const config = loadConfig({ userConfigPath: path });
|
|
99
|
+
expect(config.ui.reviewWidth).toBe(REVIEW_MAX_WIDTH); // out-of-range width clamps
|
|
100
|
+
expect(config.ui.reviewState).toBe("compact");
|
|
101
|
+
} finally {
|
|
102
|
+
rmSync(dir, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("[ui] ignores an unknown review_state and a non-numeric width", () => {
|
|
107
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg7-"));
|
|
108
|
+
const path = join(dir, "config.toml");
|
|
109
|
+
writeFileSync(path, `[ui]\nreview_width = "wide"\nreview_state = "sideways"\n`);
|
|
110
|
+
try {
|
|
111
|
+
const config = loadConfig({ userConfigPath: path });
|
|
112
|
+
expect(config.ui.reviewWidth).toBe(REVIEW_DEFAULT_WIDTH);
|
|
113
|
+
expect(config.ui.reviewState).toBe("expanded");
|
|
114
|
+
} finally {
|
|
115
|
+
rmSync(dir, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("persistReviewWidth and persistReviewState round-trip through the config file", () => {
|
|
120
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg8-"));
|
|
121
|
+
const path = join(dir, "config.toml");
|
|
122
|
+
try {
|
|
123
|
+
persistReviewWidth(42, path);
|
|
124
|
+
persistReviewState("hidden", path);
|
|
125
|
+
const config = loadConfig({ userConfigPath: path });
|
|
126
|
+
expect(config.ui.reviewWidth).toBe(42);
|
|
127
|
+
expect(config.ui.reviewState).toBe("hidden");
|
|
128
|
+
// a second write replaces the key in place rather than appending a duplicate
|
|
129
|
+
persistReviewWidth(30, path);
|
|
130
|
+
expect(loadConfig({ userConfigPath: path }).ui.reviewWidth).toBe(30);
|
|
56
131
|
} finally {
|
|
57
132
|
rmSync(dir, { recursive: true, force: true });
|
|
58
133
|
}
|
|
@@ -61,12 +136,12 @@ describe("loadConfig", () => {
|
|
|
61
136
|
|
|
62
137
|
describe("integrations.obsidian config", () => {
|
|
63
138
|
test("defaults when the section is absent", () => {
|
|
64
|
-
const
|
|
65
|
-
expect(
|
|
66
|
-
expect(
|
|
67
|
-
expect(
|
|
68
|
-
expect(
|
|
69
|
-
expect(
|
|
139
|
+
const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
|
|
140
|
+
expect(config.integrations.obsidian.vault).toBeUndefined();
|
|
141
|
+
expect(config.integrations.obsidian.folder).toBe("cueloop");
|
|
142
|
+
expect(config.integrations.obsidian.filenameFormat).toBe("{YYYY}-{MM}-{DD} - {title}");
|
|
143
|
+
expect(config.integrations.obsidian.separator).toBe("space");
|
|
144
|
+
expect(config.integrations.obsidian.exportOn).toBe("manual");
|
|
70
145
|
});
|
|
71
146
|
|
|
72
147
|
test("user config sets the section; invalid enum values are ignored", () => {
|
|
@@ -77,12 +152,12 @@ describe("integrations.obsidian config", () => {
|
|
|
77
152
|
`[integrations.obsidian]\nvault = "/notes/vault"\nfolder = "plans"\nexportOn = "approve"\nseparator = "comma"\n`,
|
|
78
153
|
);
|
|
79
154
|
try {
|
|
80
|
-
const
|
|
81
|
-
expect(
|
|
82
|
-
expect(
|
|
83
|
-
expect(
|
|
84
|
-
expect(
|
|
85
|
-
expect(
|
|
155
|
+
const config = loadConfig({ userConfigPath: path });
|
|
156
|
+
expect(config.integrations.obsidian.vault).toBe("/notes/vault");
|
|
157
|
+
expect(config.integrations.obsidian.folder).toBe("plans");
|
|
158
|
+
expect(config.integrations.obsidian.exportOn).toBe("approve");
|
|
159
|
+
expect(config.integrations.obsidian.separator).toBe("space"); // invalid value falls back
|
|
160
|
+
expect(config.keys["comment"]).toEqual(["c"]); // other sections untouched
|
|
86
161
|
} finally {
|
|
87
162
|
rmSync(dir, { recursive: true, force: true });
|
|
88
163
|
}
|
|
@@ -96,10 +171,10 @@ describe("integrations.obsidian config", () => {
|
|
|
96
171
|
Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
|
|
97
172
|
writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[integrations.obsidian]\nfolder = "repo-plans"\n`);
|
|
98
173
|
try {
|
|
99
|
-
const
|
|
100
|
-
expect(
|
|
101
|
-
expect(
|
|
102
|
-
expect(
|
|
174
|
+
const config = loadConfig({ userConfigPath: user, repoRoot });
|
|
175
|
+
expect(config.integrations.obsidian.vault).toBe("/user/vault"); // user layer survives
|
|
176
|
+
expect(config.integrations.obsidian.exportOn).toBe("resolve");
|
|
177
|
+
expect(config.integrations.obsidian.folder).toBe("repo-plans"); // repo layer wins
|
|
103
178
|
} finally {
|
|
104
179
|
rmSync(dir, { recursive: true, force: true });
|
|
105
180
|
}
|
package/src/config.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Layered TOML config
|
|
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, [
|
|
4
|
+
* [theme] per-token overrides, [ui] auto_close + editor + the review-panel
|
|
5
|
+
* layout (review_width + review_state), [integrations.obsidian] notes-vault
|
|
6
|
+
* export.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
8
10
|
import { homedir } from "node:os";
|
|
9
11
|
import { join } from "node:path";
|
|
10
12
|
import { OBSIDIAN_DEFAULTS, type ObsidianConfig } from "@cueloop/integration-obsidian";
|
|
13
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
14
|
+
import { dirname } from "node:path";
|
|
11
15
|
import { DARK, type Theme } from "./theme";
|
|
16
|
+
import { REVIEW_DEFAULT_WIDTH, clampWidth, type ReviewPanelMode } from "./review-panel";
|
|
12
17
|
|
|
13
18
|
export interface KeymapConfig {
|
|
14
19
|
[action: string]: string | string[];
|
|
@@ -18,9 +23,17 @@ export interface IntegrationsConfig {
|
|
|
18
23
|
obsidian: ObsidianConfig;
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
/** Post-submit behavior: "off" prompts, 0 closes instantly, N counts down. */
|
|
27
|
+
export type AutoClose = "off" | number;
|
|
28
|
+
|
|
21
29
|
export interface CueloopConfig {
|
|
22
30
|
keys: Record<string, string[]>;
|
|
23
31
|
theme: Theme;
|
|
32
|
+
/**
|
|
33
|
+
* ui.reviewState / ui.reviewWidth are CLIENT VIEW STATE: the review panel's
|
|
34
|
+
* collapse mode and expanded-rail width, persisted so they survive restarts.
|
|
35
|
+
*/
|
|
36
|
+
ui: { autoClose: AutoClose; editor?: string; reviewState: ReviewPanelMode; reviewWidth: number };
|
|
24
37
|
integrations: IntegrationsConfig;
|
|
25
38
|
}
|
|
26
39
|
|
|
@@ -40,6 +53,10 @@ export const DEFAULT_KEYS: Record<string, string[]> = {
|
|
|
40
53
|
delete_annotation: ["backspace"],
|
|
41
54
|
submit: ["return", "enter"],
|
|
42
55
|
quit: ["q"],
|
|
56
|
+
walk: ["w"],
|
|
57
|
+
review_cycle: ["b"],
|
|
58
|
+
review_wider: ["]"],
|
|
59
|
+
review_narrower: ["["],
|
|
43
60
|
};
|
|
44
61
|
|
|
45
62
|
function parseToml(text: string): Record<string, unknown> {
|
|
@@ -51,6 +68,7 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
|
|
|
51
68
|
const out: CueloopConfig = {
|
|
52
69
|
keys: { ...base.keys },
|
|
53
70
|
theme: { ...base.theme },
|
|
71
|
+
ui: { ...base.ui },
|
|
54
72
|
integrations: { obsidian: { ...base.integrations.obsidian } },
|
|
55
73
|
};
|
|
56
74
|
const keys = raw["keys"] as KeymapConfig | undefined;
|
|
@@ -59,6 +77,20 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
|
|
|
59
77
|
out.keys[action] = Array.isArray(combo) ? combo : [combo];
|
|
60
78
|
}
|
|
61
79
|
}
|
|
80
|
+
const ui = raw["ui"] as
|
|
81
|
+
| { auto_close?: unknown; editor?: unknown; review_width?: unknown; review_state?: unknown }
|
|
82
|
+
| undefined;
|
|
83
|
+
if (ui && ui.auto_close !== undefined) {
|
|
84
|
+
if (ui.auto_close === "off") out.ui.autoClose = "off";
|
|
85
|
+
else if (typeof ui.auto_close === "number" && ui.auto_close >= 0) out.ui.autoClose = ui.auto_close;
|
|
86
|
+
}
|
|
87
|
+
if (ui && typeof ui.editor === "string" && ui.editor.trim()) out.ui.editor = ui.editor.trim();
|
|
88
|
+
if (ui && typeof ui.review_width === "number" && Number.isFinite(ui.review_width)) {
|
|
89
|
+
out.ui.reviewWidth = clampWidth(ui.review_width);
|
|
90
|
+
}
|
|
91
|
+
if (ui && (ui.review_state === "expanded" || ui.review_state === "compact" || ui.review_state === "hidden")) {
|
|
92
|
+
out.ui.reviewState = ui.review_state;
|
|
93
|
+
}
|
|
62
94
|
const theme = raw["theme"] as Partial<Record<keyof Theme, string>> | undefined;
|
|
63
95
|
if (theme) {
|
|
64
96
|
for (const [token, value] of Object.entries(theme)) {
|
|
@@ -70,29 +102,27 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
|
|
|
70
102
|
const integrations = raw["integrations"] as Record<string, unknown> | undefined;
|
|
71
103
|
const obsidian = integrations?.["obsidian"] as Record<string, unknown> | undefined;
|
|
72
104
|
if (obsidian) {
|
|
73
|
-
const
|
|
74
|
-
if (typeof obsidian["vault"] === "string")
|
|
75
|
-
if (typeof obsidian["folder"] === "string")
|
|
76
|
-
if (typeof obsidian["filenameFormat"] === "string")
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
105
|
+
const obsidianConfig = out.integrations.obsidian;
|
|
106
|
+
if (typeof obsidian["vault"] === "string") obsidianConfig.vault = obsidian["vault"];
|
|
107
|
+
if (typeof obsidian["folder"] === "string") obsidianConfig.folder = obsidian["folder"];
|
|
108
|
+
if (typeof obsidian["filenameFormat"] === "string") obsidianConfig.filenameFormat = obsidian["filenameFormat"];
|
|
109
|
+
const separator = obsidian["separator"];
|
|
110
|
+
if (separator === "space" || separator === "dash" || separator === "underscore") obsidianConfig.separator = separator;
|
|
111
|
+
const exportOn = obsidian["exportOn"];
|
|
112
|
+
if (exportOn === "approve" || exportOn === "resolve" || exportOn === "manual") obsidianConfig.exportOn = exportOn;
|
|
81
113
|
}
|
|
82
114
|
return out;
|
|
83
115
|
}
|
|
84
116
|
|
|
85
|
-
export function loadConfig(
|
|
117
|
+
export function loadConfig(options: { repoRoot?: string; userConfigPath?: string } = {}): CueloopConfig {
|
|
86
118
|
let config: CueloopConfig = {
|
|
87
119
|
keys: { ...DEFAULT_KEYS },
|
|
88
120
|
theme: { ...DARK },
|
|
121
|
+
ui: { autoClose: "off", reviewState: "expanded", reviewWidth: REVIEW_DEFAULT_WIDTH },
|
|
89
122
|
integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
|
|
90
123
|
};
|
|
91
|
-
const userPath =
|
|
92
|
-
|
|
93
|
-
process.env.CUELOOP_CONFIG ??
|
|
94
|
-
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml");
|
|
95
|
-
for (const path of [userPath, opts.repoRoot ? join(opts.repoRoot, ".cueloop", "config.toml") : undefined]) {
|
|
124
|
+
const userPath = userConfigPathFrom(options.userConfigPath);
|
|
125
|
+
for (const path of [userPath, options.repoRoot ? join(options.repoRoot, ".cueloop", "config.toml") : undefined]) {
|
|
96
126
|
if (!path || !existsSync(path)) continue;
|
|
97
127
|
try {
|
|
98
128
|
config = layer(config, parseToml(readFileSync(path, "utf8")));
|
|
@@ -111,3 +141,48 @@ export function actionFor(keys: Record<string, string[]>, name: string, shift: b
|
|
|
111
141
|
}
|
|
112
142
|
return undefined;
|
|
113
143
|
}
|
|
144
|
+
|
|
145
|
+
function userConfigPathFrom(userConfigPath?: string): string {
|
|
146
|
+
return (
|
|
147
|
+
userConfigPath ??
|
|
148
|
+
process.env.CUELOOP_CONFIG ??
|
|
149
|
+
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml")
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Persist one `[ui]` key into the user config file. Line-level TOML surgery on
|
|
155
|
+
* the keys we own: replace the existing assignment, or drop it under an `[ui]`
|
|
156
|
+
* section (appending the section when absent) - a full TOML writer is not worth
|
|
157
|
+
* its weight here.
|
|
158
|
+
*/
|
|
159
|
+
function persistUiSetting(key: string, rendered: string, userConfigPath?: string): void {
|
|
160
|
+
const path = userConfigPathFrom(userConfigPath);
|
|
161
|
+
let text = "";
|
|
162
|
+
if (existsSync(path)) text = readFileSync(path, "utf8");
|
|
163
|
+
const assignment = new RegExp(`^(\\s*)${key}\\s*=.*$`, "m");
|
|
164
|
+
if (assignment.test(text)) {
|
|
165
|
+
text = text.replace(assignment, `$1${key} = ${rendered}`);
|
|
166
|
+
} else if (/^\[ui\]/m.test(text)) {
|
|
167
|
+
text = text.replace(/^\[ui\]\s*$/m, `[ui]\n${key} = ${rendered}`);
|
|
168
|
+
} else {
|
|
169
|
+
text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[ui]\n${key} = ${rendered}\n`;
|
|
170
|
+
}
|
|
171
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
172
|
+
writeFileSync(path, text);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Persist the auto-close choice (`[ui] auto_close`) into the user config. */
|
|
176
|
+
export function persistAutoClose(value: AutoClose, userConfigPath?: string): void {
|
|
177
|
+
persistUiSetting("auto_close", value === "off" ? '"off"' : String(value), userConfigPath);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Persist the expanded-rail width (`[ui] review_width`) into the user config. */
|
|
181
|
+
export function persistReviewWidth(width: number, userConfigPath?: string): void {
|
|
182
|
+
persistUiSetting("review_width", String(clampWidth(width)), userConfigPath);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Persist the review-panel collapse mode (`[ui] review_state`) into the config. */
|
|
186
|
+
export function persistReviewState(state: ReviewPanelMode, userConfigPath?: string): void {
|
|
187
|
+
persistUiSetting("review_state", `"${state}"`, userConfigPath);
|
|
188
|
+
}
|