@cueloop/client 0.1.0-alpha.20 → 0.1.0-alpha.22
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 +5 -5
- package/src/App.autoclose.test.tsx +29 -5
- package/src/App.diff.test.tsx +20 -0
- package/src/App.export.test.tsx +12 -8
- package/src/App.plan-review.test.tsx +182 -7
- package/src/App.readonly.test.tsx +19 -5
- package/src/App.rendering.test.tsx +21 -2
- package/src/App.review-panel.test.tsx +120 -0
- package/src/App.submit-confirm.test.tsx +57 -6
- package/src/App.test.tsx +62 -5
- package/src/App.tsx +147 -185
- package/src/App.walk.test.tsx +63 -8
- 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.tsx +66 -13
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +116 -0
- package/src/components/ReviewRail.tsx +30 -5
- package/src/components/__snapshots__/stories.test.tsx.snap +140 -0
- package/src/components/primitives/Card.tsx +2 -1
- package/src/components/primitives/Dialog.tsx +2 -1
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/stories-app.tsx +2 -1
- package/src/components/stories.test.tsx +5 -0
- package/src/config.test.ts +113 -1
- package/src/config.ts +58 -22
- package/src/editor.test.ts +24 -0
- package/src/herdr.test.ts +4 -0
- package/src/index.ts +2 -0
- package/src/integrations.test.ts +12 -0
- package/src/intent-dispatch.test.ts +213 -0
- package/src/intent-dispatch.ts +270 -0
- package/src/key-bindings.test.ts +16 -5
- package/src/keymap.test.ts +141 -9
- package/src/keymap.ts +54 -7
- package/src/review-panel.test.ts +111 -0
- package/src/review-panel.ts +76 -0
- package/src/serve.test.ts +7 -0
- package/src/session-controller.ts +39 -4
- package/src/share.ts +47 -0
- package/src/view-diff.test.ts +11 -0
- package/src/view-diff.ts +6 -6
- package/src/view-plan.test.ts +81 -0
- package/src/view-plan.ts +3 -0
- package/src/walk.test.ts +6 -0
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import React from "react";
|
|
8
8
|
import type { Theme } from "../../theme";
|
|
9
9
|
import { useComponentTheme } from "../theme-context";
|
|
10
|
+
import { FRAME_BORDER_STYLE } from "./frame";
|
|
10
11
|
|
|
11
12
|
export interface DialogProps {
|
|
12
13
|
isOpen: boolean;
|
|
@@ -38,7 +39,7 @@ export function Dialog({ isOpen, title, width, height, theme, children }: Dialog
|
|
|
38
39
|
width,
|
|
39
40
|
height,
|
|
40
41
|
border: true,
|
|
41
|
-
borderStyle:
|
|
42
|
+
borderStyle: FRAME_BORDER_STYLE,
|
|
42
43
|
borderColor: tokens.accent,
|
|
43
44
|
backgroundColor: tokens.panel,
|
|
44
45
|
flexDirection: "column",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locks the single frame border rule: every bordered frame reads this token,
|
|
3
|
+
* so a regression to square corners (or any other style) fails here first,
|
|
4
|
+
* before the slower VRT snapshots catch it. Decision: rounded (issue #116).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { expect, test } from "bun:test";
|
|
8
|
+
import { FRAME_BORDER_STYLE } from "./frame";
|
|
9
|
+
|
|
10
|
+
test("frames use the rounded border style", () => {
|
|
11
|
+
expect(FRAME_BORDER_STYLE).toBe("rounded");
|
|
12
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one frame border rule for the design system. Every bordered frame -
|
|
3
|
+
* cards, dialogs, and the stories gallery chrome - reads its corner style
|
|
4
|
+
* from FRAME_BORDER_STYLE, so the rounded-corner decision lives in a single
|
|
5
|
+
* place and can never drift between frames. Buttons stay text-first and
|
|
6
|
+
* borderless: the frame they sit in carries the border, not the button.
|
|
7
|
+
*
|
|
8
|
+
* Decision: rounded on the frames, not on the buttons (issue #116).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { BorderStyle } from "@opentui/core";
|
|
12
|
+
|
|
13
|
+
/** Corner style shared by every bordered frame in the app. */
|
|
14
|
+
export const FRAME_BORDER_STYLE: BorderStyle = "rounded";
|
|
@@ -11,6 +11,7 @@ import { createRoot, useKeyboard } from "@opentui/react";
|
|
|
11
11
|
import { DARK } from "../theme";
|
|
12
12
|
import { ThemeProvider } from "./theme-context";
|
|
13
13
|
import { loadStories, type LoadedStory } from "./story";
|
|
14
|
+
import { FRAME_BORDER_STYLE } from "./primitives/frame";
|
|
14
15
|
|
|
15
16
|
function StoriesApp({ stories, onExit }: { stories: LoadedStory[]; onExit: () => void }): React.ReactNode {
|
|
16
17
|
const moduleTitles = useMemo(() => [...new Set(stories.map((story) => story.moduleTitle))], [stories]);
|
|
@@ -60,7 +61,7 @@ function StoriesApp({ stories, onExit }: { stories: LoadedStory[]; onExit: () =>
|
|
|
60
61
|
flexGrow: 1,
|
|
61
62
|
flexDirection: "column",
|
|
62
63
|
border: true,
|
|
63
|
-
borderStyle:
|
|
64
|
+
borderStyle: FRAME_BORDER_STYLE,
|
|
64
65
|
borderColor: DARK.border,
|
|
65
66
|
}}
|
|
66
67
|
title={` ${current.storyName} (${moduleStories.indexOf(current) + 1}/${moduleStories.length}) `}
|
|
@@ -28,10 +28,15 @@ describe("stories catalog", () => {
|
|
|
28
28
|
|
|
29
29
|
for (const { moduleTitle, storyName, story } of stories) {
|
|
30
30
|
test(`${moduleTitle}/${storyName} renders, snapshots, and carries its colors`, async () => {
|
|
31
|
+
// Arrange
|
|
31
32
|
const size = story.size ?? { width: 80, height: 24 };
|
|
33
|
+
|
|
34
|
+
// Act
|
|
32
35
|
const setup = await testRender(story.render(), size);
|
|
33
36
|
await setup.waitForVisualIdle();
|
|
34
37
|
const frame = setup.captureCharFrame();
|
|
38
|
+
|
|
39
|
+
// Assert
|
|
35
40
|
expect(frame).toMatchSnapshot(`${moduleTitle}/${storyName}`);
|
|
36
41
|
if (story.expectedColors?.length) {
|
|
37
42
|
const seenColors = new Set<string>();
|
package/src/config.test.ts
CHANGED
|
@@ -2,25 +2,34 @@ 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", () => {
|
|
11
|
+
// Act
|
|
10
12
|
const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
|
|
13
|
+
|
|
14
|
+
// Assert
|
|
11
15
|
expect(config.keys["comment"]).toEqual(["c"]);
|
|
12
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 {
|
|
29
|
+
// Act
|
|
23
30
|
const config = loadConfig({ userConfigPath: path });
|
|
31
|
+
|
|
32
|
+
// Assert
|
|
24
33
|
expect(config.keys["comment"]).toEqual(["a"]);
|
|
25
34
|
expect(config.keys["submit"]).toEqual(["return", "S"]);
|
|
26
35
|
expect(config.keys["cut"]).toEqual(["x"]); // untouched defaults survive
|
|
@@ -32,14 +41,19 @@ describe("loadConfig", () => {
|
|
|
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 {
|
|
53
|
+
// Act
|
|
42
54
|
const config = loadConfig({ userConfigPath: user, repoRoot });
|
|
55
|
+
|
|
56
|
+
// Assert
|
|
43
57
|
expect(config.keys["comment"]).toEqual(["z"]);
|
|
44
58
|
} finally {
|
|
45
59
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -47,11 +61,16 @@ describe("loadConfig", () => {
|
|
|
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
|
+
|
|
53
69
|
try {
|
|
70
|
+
// Act
|
|
54
71
|
const config = loadConfig({ userConfigPath: path });
|
|
72
|
+
|
|
73
|
+
// Assert
|
|
55
74
|
expect(config.keys["comment"]).toEqual(["c"]);
|
|
56
75
|
} finally {
|
|
57
76
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -59,11 +78,16 @@ describe("loadConfig", () => {
|
|
|
59
78
|
});
|
|
60
79
|
|
|
61
80
|
test("[ui] parses auto_close and the editor override", () => {
|
|
81
|
+
// Arrange
|
|
62
82
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg4-"));
|
|
63
83
|
const path = join(dir, "config.toml");
|
|
64
84
|
writeFileSync(path, `[ui]\nauto_close = 3\neditor = "code --wait"\n`);
|
|
85
|
+
|
|
65
86
|
try {
|
|
87
|
+
// Act
|
|
66
88
|
const config = loadConfig({ userConfigPath: path });
|
|
89
|
+
|
|
90
|
+
// Assert
|
|
67
91
|
expect(config.ui.autoClose).toBe(3);
|
|
68
92
|
expect(config.ui.editor).toBe("code --wait");
|
|
69
93
|
} finally {
|
|
@@ -72,21 +96,98 @@ describe("loadConfig", () => {
|
|
|
72
96
|
});
|
|
73
97
|
|
|
74
98
|
test("[ui] editor defaults to undefined and ignores a blank value", () => {
|
|
99
|
+
// Arrange
|
|
75
100
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg5-"));
|
|
76
101
|
const path = join(dir, "config.toml");
|
|
77
102
|
writeFileSync(path, `[ui]\neditor = " "\n`);
|
|
103
|
+
|
|
78
104
|
try {
|
|
105
|
+
// Assert
|
|
79
106
|
expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.editor).toBeUndefined();
|
|
80
107
|
expect(loadConfig({ userConfigPath: path }).ui.editor).toBeUndefined();
|
|
81
108
|
} finally {
|
|
82
109
|
rmSync(dir, { recursive: true, force: true });
|
|
83
110
|
}
|
|
84
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
|
+
|
|
128
|
+
try {
|
|
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);
|
|
179
|
+
} finally {
|
|
180
|
+
rmSync(dir, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
85
183
|
});
|
|
86
184
|
|
|
87
185
|
describe("integrations.obsidian config", () => {
|
|
88
186
|
test("defaults when the section is absent", () => {
|
|
187
|
+
// Act
|
|
89
188
|
const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
|
|
189
|
+
|
|
190
|
+
// Assert
|
|
90
191
|
expect(config.integrations.obsidian.vault).toBeUndefined();
|
|
91
192
|
expect(config.integrations.obsidian.folder).toBe("cueloop");
|
|
92
193
|
expect(config.integrations.obsidian.filenameFormat).toBe("{YYYY}-{MM}-{DD} - {title}");
|
|
@@ -95,14 +196,19 @@ describe("integrations.obsidian config", () => {
|
|
|
95
196
|
});
|
|
96
197
|
|
|
97
198
|
test("user config sets the section; invalid enum values are ignored", () => {
|
|
199
|
+
// Arrange
|
|
98
200
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs-"));
|
|
99
201
|
const path = join(dir, "config.toml");
|
|
100
202
|
writeFileSync(
|
|
101
203
|
path,
|
|
102
204
|
`[integrations.obsidian]\nvault = "/notes/vault"\nfolder = "plans"\nexportOn = "approve"\nseparator = "comma"\n`,
|
|
103
205
|
);
|
|
206
|
+
|
|
104
207
|
try {
|
|
208
|
+
// Act
|
|
105
209
|
const config = loadConfig({ userConfigPath: path });
|
|
210
|
+
|
|
211
|
+
// Assert
|
|
106
212
|
expect(config.integrations.obsidian.vault).toBe("/notes/vault");
|
|
107
213
|
expect(config.integrations.obsidian.folder).toBe("plans");
|
|
108
214
|
expect(config.integrations.obsidian.exportOn).toBe("approve");
|
|
@@ -114,14 +220,19 @@ describe("integrations.obsidian config", () => {
|
|
|
114
220
|
});
|
|
115
221
|
|
|
116
222
|
test("repo config layers over user config for the section", () => {
|
|
223
|
+
// Arrange
|
|
117
224
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs2-"));
|
|
118
225
|
const user = join(dir, "user.toml");
|
|
119
226
|
const repoRoot = join(dir, "repo");
|
|
120
227
|
writeFileSync(user, `[integrations.obsidian]\nvault = "/user/vault"\nexportOn = "resolve"\n`);
|
|
121
228
|
Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
|
|
122
229
|
writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[integrations.obsidian]\nfolder = "repo-plans"\n`);
|
|
230
|
+
|
|
123
231
|
try {
|
|
232
|
+
// Act
|
|
124
233
|
const config = loadConfig({ userConfigPath: user, repoRoot });
|
|
234
|
+
|
|
235
|
+
// Assert
|
|
125
236
|
expect(config.integrations.obsidian.vault).toBe("/user/vault"); // user layer survives
|
|
126
237
|
expect(config.integrations.obsidian.exportOn).toBe("resolve");
|
|
127
238
|
expect(config.integrations.obsidian.folder).toBe("repo-plans"); // repo layer wins
|
|
@@ -133,6 +244,7 @@ describe("integrations.obsidian config", () => {
|
|
|
133
244
|
|
|
134
245
|
describe("actionFor", () => {
|
|
135
246
|
test("resolves keys and shifted keys to actions", () => {
|
|
247
|
+
// Assert
|
|
136
248
|
expect(actionFor(DEFAULT_KEYS, "j", false)).toBe("down");
|
|
137
249
|
expect(actionFor(DEFAULT_KEYS, "g", true)).toBe("bottom"); // G
|
|
138
250
|
expect(actionFor(DEFAULT_KEYS, "return", false)).toBe("submit");
|
package/src/config.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Layered TOML config: built-in defaults → user config → trusted repo
|
|
3
3
|
* config → env. Sections: [keys] action = "combo" (every action rebindable),
|
|
4
|
-
* [theme] per-token overrides, [ui] auto_close + editor
|
|
5
|
-
* notes-vault
|
|
4
|
+
* [theme] per-token overrides, [ui] auto_close + editor + the review-panel
|
|
5
|
+
* layout (review_width + review_state), [integrations.obsidian] notes-vault
|
|
6
|
+
* export.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -12,6 +13,7 @@ import { OBSIDIAN_DEFAULTS, type ObsidianConfig } from "@cueloop/integration-obs
|
|
|
12
13
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
13
14
|
import { dirname } from "node:path";
|
|
14
15
|
import { DARK, type Theme } from "./theme";
|
|
16
|
+
import { REVIEW_DEFAULT_WIDTH, clampWidth, type ReviewPanelMode } from "./review-panel";
|
|
15
17
|
|
|
16
18
|
export interface KeymapConfig {
|
|
17
19
|
[action: string]: string | string[];
|
|
@@ -27,7 +29,11 @@ export type AutoClose = "off" | number;
|
|
|
27
29
|
export interface CueloopConfig {
|
|
28
30
|
keys: Record<string, string[]>;
|
|
29
31
|
theme: Theme;
|
|
30
|
-
|
|
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 };
|
|
31
37
|
integrations: IntegrationsConfig;
|
|
32
38
|
}
|
|
33
39
|
|
|
@@ -46,8 +52,12 @@ export const DEFAULT_KEYS: Record<string, string[]> = {
|
|
|
46
52
|
prev_annotation: ["p"],
|
|
47
53
|
delete_annotation: ["backspace"],
|
|
48
54
|
submit: ["return", "enter"],
|
|
55
|
+
share: ["S"],
|
|
49
56
|
quit: ["q"],
|
|
50
57
|
walk: ["w"],
|
|
58
|
+
review_cycle: ["b"],
|
|
59
|
+
review_wider: ["]"],
|
|
60
|
+
review_narrower: ["["],
|
|
51
61
|
};
|
|
52
62
|
|
|
53
63
|
function parseToml(text: string): Record<string, unknown> {
|
|
@@ -68,12 +78,20 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
|
|
|
68
78
|
out.keys[action] = Array.isArray(combo) ? combo : [combo];
|
|
69
79
|
}
|
|
70
80
|
}
|
|
71
|
-
const ui = raw["ui"] as
|
|
81
|
+
const ui = raw["ui"] as
|
|
82
|
+
| { auto_close?: unknown; editor?: unknown; review_width?: unknown; review_state?: unknown }
|
|
83
|
+
| undefined;
|
|
72
84
|
if (ui && ui.auto_close !== undefined) {
|
|
73
85
|
if (ui.auto_close === "off") out.ui.autoClose = "off";
|
|
74
86
|
else if (typeof ui.auto_close === "number" && ui.auto_close >= 0) out.ui.autoClose = ui.auto_close;
|
|
75
87
|
}
|
|
76
88
|
if (ui && typeof ui.editor === "string" && ui.editor.trim()) out.ui.editor = ui.editor.trim();
|
|
89
|
+
if (ui && typeof ui.review_width === "number" && Number.isFinite(ui.review_width)) {
|
|
90
|
+
out.ui.reviewWidth = clampWidth(ui.review_width);
|
|
91
|
+
}
|
|
92
|
+
if (ui && (ui.review_state === "expanded" || ui.review_state === "compact" || ui.review_state === "hidden")) {
|
|
93
|
+
out.ui.reviewState = ui.review_state;
|
|
94
|
+
}
|
|
77
95
|
const theme = raw["theme"] as Partial<Record<keyof Theme, string>> | undefined;
|
|
78
96
|
if (theme) {
|
|
79
97
|
for (const [token, value] of Object.entries(theme)) {
|
|
@@ -101,13 +119,10 @@ export function loadConfig(options: { repoRoot?: string; userConfigPath?: string
|
|
|
101
119
|
let config: CueloopConfig = {
|
|
102
120
|
keys: { ...DEFAULT_KEYS },
|
|
103
121
|
theme: { ...DARK },
|
|
104
|
-
ui: { autoClose: "off" },
|
|
122
|
+
ui: { autoClose: "off", reviewState: "expanded", reviewWidth: REVIEW_DEFAULT_WIDTH },
|
|
105
123
|
integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
|
|
106
124
|
};
|
|
107
|
-
const userPath =
|
|
108
|
-
options.userConfigPath ??
|
|
109
|
-
process.env.CUELOOP_CONFIG ??
|
|
110
|
-
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml");
|
|
125
|
+
const userPath = userConfigPathFrom(options.userConfigPath);
|
|
111
126
|
for (const path of [userPath, options.repoRoot ? join(options.repoRoot, ".cueloop", "config.toml") : undefined]) {
|
|
112
127
|
if (!path || !existsSync(path)) continue;
|
|
113
128
|
try {
|
|
@@ -128,26 +143,47 @@ export function actionFor(keys: Record<string, string[]>, name: string, shift: b
|
|
|
128
143
|
return undefined;
|
|
129
144
|
}
|
|
130
145
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
* surgery on the one key we own: replace an existing `auto_close`, or append
|
|
134
|
-
* an `[ui]` section - a full TOML writer is not worth its weight here.
|
|
135
|
-
*/
|
|
136
|
-
export function persistAutoClose(value: AutoClose, userConfigPath?: string): void {
|
|
137
|
-
const path =
|
|
146
|
+
function userConfigPathFrom(userConfigPath?: string): string {
|
|
147
|
+
return (
|
|
138
148
|
userConfigPath ??
|
|
139
149
|
process.env.CUELOOP_CONFIG ??
|
|
140
|
-
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml")
|
|
141
|
-
|
|
150
|
+
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml")
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Persist one `[ui]` key into the user config file. Line-level TOML surgery on
|
|
156
|
+
* the keys we own: replace the existing assignment, or drop it under an `[ui]`
|
|
157
|
+
* section (appending the section when absent) - a full TOML writer is not worth
|
|
158
|
+
* its weight here.
|
|
159
|
+
*/
|
|
160
|
+
function persistUiSetting(key: string, rendered: string, userConfigPath?: string): void {
|
|
161
|
+
const path = userConfigPathFrom(userConfigPath);
|
|
142
162
|
let text = "";
|
|
143
163
|
if (existsSync(path)) text = readFileSync(path, "utf8");
|
|
144
|
-
|
|
145
|
-
|
|
164
|
+
const assignment = new RegExp(`^(\\s*)${key}\\s*=.*$`, "m");
|
|
165
|
+
if (assignment.test(text)) {
|
|
166
|
+
text = text.replace(assignment, `$1${key} = ${rendered}`);
|
|
146
167
|
} else if (/^\[ui\]/m.test(text)) {
|
|
147
|
-
text = text.replace(/^\[ui\]\s*$/m, `[ui]\
|
|
168
|
+
text = text.replace(/^\[ui\]\s*$/m, `[ui]\n${key} = ${rendered}`);
|
|
148
169
|
} else {
|
|
149
|
-
text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[ui]\
|
|
170
|
+
text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[ui]\n${key} = ${rendered}\n`;
|
|
150
171
|
}
|
|
151
172
|
mkdirSync(dirname(path), { recursive: true });
|
|
152
173
|
writeFileSync(path, text);
|
|
153
174
|
}
|
|
175
|
+
|
|
176
|
+
/** Persist the auto-close choice (`[ui] auto_close`) into the user config. */
|
|
177
|
+
export function persistAutoClose(value: AutoClose, userConfigPath?: string): void {
|
|
178
|
+
persistUiSetting("auto_close", value === "off" ? '"off"' : String(value), userConfigPath);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Persist the expanded-rail width (`[ui] review_width`) into the user config. */
|
|
182
|
+
export function persistReviewWidth(width: number, userConfigPath?: string): void {
|
|
183
|
+
persistUiSetting("review_width", String(clampWidth(width)), userConfigPath);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Persist the review-panel collapse mode (`[ui] review_state`) into the config. */
|
|
187
|
+
export function persistReviewState(state: ReviewPanelMode, userConfigPath?: string): void {
|
|
188
|
+
persistUiSetting("review_state", `"${state}"`, userConfigPath);
|
|
189
|
+
}
|
package/src/editor.test.ts
CHANGED
|
@@ -6,35 +6,42 @@ import { editInEditor, resolveEditor, resolveEditorCommand } from "./editor";
|
|
|
6
6
|
|
|
7
7
|
describe("resolveEditor", () => {
|
|
8
8
|
test("a clean environment falls back to nano so editing always works", () => {
|
|
9
|
+
// Assert
|
|
9
10
|
expect(resolveEditor(undefined, {})).toBe("nano");
|
|
10
11
|
});
|
|
11
12
|
|
|
12
13
|
test("the environment chain wins in order: CUELOOP_EDITOR, VISUAL, EDITOR", () => {
|
|
14
|
+
// Assert
|
|
13
15
|
expect(resolveEditor(undefined, { EDITOR: "vim", VISUAL: "code", CUELOOP_EDITOR: "hx" })).toBe("hx");
|
|
14
16
|
expect(resolveEditor(undefined, { EDITOR: "vim", VISUAL: "code" })).toBe("code");
|
|
15
17
|
expect(resolveEditor(undefined, { EDITOR: "vim" })).toBe("vim");
|
|
16
18
|
});
|
|
17
19
|
|
|
18
20
|
test("the [ui] editor config overrides the environment", () => {
|
|
21
|
+
// Assert
|
|
19
22
|
expect(resolveEditor("code --wait", { EDITOR: "vim" })).toBe("code --wait");
|
|
20
23
|
});
|
|
21
24
|
});
|
|
22
25
|
|
|
23
26
|
describe("resolveEditorCommand", () => {
|
|
24
27
|
test("a known GUI editor gets its wait flag appended and is trusted to wait", () => {
|
|
28
|
+
// Assert
|
|
25
29
|
expect(resolveEditorCommand("code")).toEqual({ argv: ["code", "--wait"], waits: true });
|
|
26
30
|
expect(resolveEditorCommand("subl")).toEqual({ argv: ["subl", "--new-window", "--wait"], waits: true });
|
|
27
31
|
});
|
|
28
32
|
|
|
29
33
|
test("an existing wait flag is not duplicated", () => {
|
|
34
|
+
// Assert
|
|
30
35
|
expect(resolveEditorCommand("code --wait").argv).toEqual(["code", "--wait"]);
|
|
31
36
|
});
|
|
32
37
|
|
|
33
38
|
test("a terminal editor is left alone and trusted to hold the tty", () => {
|
|
39
|
+
// Assert
|
|
34
40
|
expect(resolveEditorCommand("vim")).toEqual({ argv: ["vim"], waits: true });
|
|
35
41
|
});
|
|
36
42
|
|
|
37
43
|
test("an unknown editor is neither flagged nor trusted to wait", () => {
|
|
44
|
+
// Assert
|
|
38
45
|
expect(resolveEditorCommand("my-editor")).toEqual({ argv: ["my-editor"], waits: false });
|
|
39
46
|
});
|
|
40
47
|
});
|
|
@@ -62,12 +69,18 @@ describe("editInEditor", () => {
|
|
|
62
69
|
};
|
|
63
70
|
|
|
64
71
|
test("an editor that changes the file reports the new content", () => {
|
|
72
|
+
// Act
|
|
65
73
|
const result = editInEditor("old", "plan.md", { editor: stubEditor("writer.sh", `printf 'new' > "$1"`) });
|
|
74
|
+
|
|
75
|
+
// Assert
|
|
66
76
|
expect(result).toEqual({ content: "new", changed: true });
|
|
67
77
|
});
|
|
68
78
|
|
|
69
79
|
test("a fast, unchanged return opens the gate; confirming re-reads the saved file", () => {
|
|
80
|
+
// Arrange
|
|
70
81
|
let askedLabel = "";
|
|
82
|
+
|
|
83
|
+
// Act
|
|
71
84
|
const result = editInEditor("keep", "plan.md", {
|
|
72
85
|
editor: stubEditor("gui-like.sh", "exit 0"),
|
|
73
86
|
now: instant(),
|
|
@@ -77,21 +90,29 @@ describe("editInEditor", () => {
|
|
|
77
90
|
return true;
|
|
78
91
|
},
|
|
79
92
|
});
|
|
93
|
+
|
|
94
|
+
// Assert
|
|
80
95
|
expect(askedLabel).toContain("gui-like.sh");
|
|
81
96
|
expect(result).toEqual({ content: "saved in the GUI", changed: true });
|
|
82
97
|
});
|
|
83
98
|
|
|
84
99
|
test("declining the gate keeps the original content", () => {
|
|
100
|
+
// Act
|
|
85
101
|
const result = editInEditor("keep", "plan.md", {
|
|
86
102
|
editor: stubEditor("gui-like.sh", "exit 0"),
|
|
87
103
|
now: instant(),
|
|
88
104
|
confirmSaved: () => false,
|
|
89
105
|
});
|
|
106
|
+
|
|
107
|
+
// Assert
|
|
90
108
|
expect(result).toEqual({ content: "keep", changed: false });
|
|
91
109
|
});
|
|
92
110
|
|
|
93
111
|
test("a terminal editor that makes no change never opens the gate", () => {
|
|
112
|
+
// Arrange
|
|
94
113
|
let asked = false;
|
|
114
|
+
|
|
115
|
+
// Act
|
|
95
116
|
const result = editInEditor("keep", "plan.md", {
|
|
96
117
|
editor: stubEditor("vi", "exit 0"),
|
|
97
118
|
now: instant(),
|
|
@@ -100,11 +121,14 @@ describe("editInEditor", () => {
|
|
|
100
121
|
return true;
|
|
101
122
|
},
|
|
102
123
|
});
|
|
124
|
+
|
|
125
|
+
// Assert
|
|
103
126
|
expect(asked).toBeFalse();
|
|
104
127
|
expect(result).toEqual({ content: "keep", changed: false });
|
|
105
128
|
});
|
|
106
129
|
|
|
107
130
|
test("a nonzero editor exit throws", () => {
|
|
131
|
+
// Assert
|
|
108
132
|
expect(() => editInEditor("x", "plan.md", { editor: stubEditor("fail.sh", "exit 3") })).toThrow("editor exited 3");
|
|
109
133
|
});
|
|
110
134
|
});
|
package/src/herdr.test.ts
CHANGED
|
@@ -15,6 +15,7 @@ afterEach(() => {
|
|
|
15
15
|
|
|
16
16
|
describe("focusHerdrPane", () => {
|
|
17
17
|
test("resolves the pane's tab and focuses it", () => {
|
|
18
|
+
// Arrange
|
|
18
19
|
const log = join(dir, "calls.log");
|
|
19
20
|
const stub = join(dir, "herdr");
|
|
20
21
|
writeFileSync(
|
|
@@ -25,6 +26,8 @@ if [ "$1" = "pane" ]; then echo '{"result":{"pane":{"tab_id":"w1:t3"}}}'; fi
|
|
|
25
26
|
`,
|
|
26
27
|
);
|
|
27
28
|
Bun.spawnSync(["chmod", "+x", stub]);
|
|
29
|
+
|
|
30
|
+
// Assert
|
|
28
31
|
expect(focusHerdrPane(stub, "w1:p1")).toBe(true);
|
|
29
32
|
const calls = readFileSync(log, "utf8").trim().split("\n");
|
|
30
33
|
expect(calls[0]).toBe("pane get w1:p1");
|
|
@@ -32,6 +35,7 @@ if [ "$1" = "pane" ]; then echo '{"result":{"pane":{"tab_id":"w1:t3"}}}'; fi
|
|
|
32
35
|
});
|
|
33
36
|
|
|
34
37
|
test("a missing binary never throws", () => {
|
|
38
|
+
// Assert
|
|
35
39
|
expect(focusHerdrPane(join(dir, "nope"), "w1:p1")).toBe(false);
|
|
36
40
|
});
|
|
37
41
|
});
|
package/src/index.ts
CHANGED
package/src/integrations.test.ts
CHANGED
|
@@ -9,20 +9,32 @@ function integrations(overrides: Partial<typeof OBSIDIAN_DEFAULTS> = {}): Integr
|
|
|
9
9
|
|
|
10
10
|
describe("loadBundledExporters", () => {
|
|
11
11
|
test("composes the obsidian integration into one named exporter", async () => {
|
|
12
|
+
// Act
|
|
12
13
|
const exporters = await loadBundledExporters(integrations());
|
|
14
|
+
|
|
15
|
+
// Assert
|
|
13
16
|
expect(exporters.map((exporter) => exporter.name)).toEqual(["obsidian"]);
|
|
14
17
|
});
|
|
15
18
|
|
|
16
19
|
test("runsOn mirrors the configured export policy", async () => {
|
|
20
|
+
// Arrange
|
|
17
21
|
const onApprove = (await loadBundledExporters(integrations({ exportOn: "approve" })))[0]!;
|
|
22
|
+
|
|
23
|
+
// Assert
|
|
18
24
|
expect(onApprove.runsOn("approve")).toBeTrue();
|
|
19
25
|
expect(onApprove.runsOn("request_changes")).toBeFalse();
|
|
20
26
|
|
|
27
|
+
// Arrange
|
|
21
28
|
const onResolve = (await loadBundledExporters(integrations({ exportOn: "resolve" })))[0]!;
|
|
29
|
+
|
|
30
|
+
// Assert
|
|
22
31
|
expect(onResolve.runsOn("approve")).toBeTrue();
|
|
23
32
|
expect(onResolve.runsOn("request_changes")).toBeTrue();
|
|
24
33
|
|
|
34
|
+
// Arrange
|
|
25
35
|
const manual = (await loadBundledExporters(integrations({ exportOn: "manual" })))[0]!;
|
|
36
|
+
|
|
37
|
+
// Assert
|
|
26
38
|
expect(manual.runsOn("approve")).toBeFalse();
|
|
27
39
|
expect(manual.runsOn("request_changes")).toBeFalse();
|
|
28
40
|
});
|