@cueloop/client 0.1.0-alpha.21 → 0.1.0-alpha.23

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.
@@ -8,7 +8,7 @@
8
8
 
9
9
  import React, { forwardRef, useImperativeHandle, useRef } from "react";
10
10
  import type { ScrollBoxRenderable } from "@opentui/core";
11
- import type { Annotation, ReviewSession } from "@cueloop/schema";
11
+ import { isAddressed, type Annotation, type ReviewSession } from "@cueloop/schema";
12
12
  import type { Theme } from "../theme";
13
13
  import { useComponentTheme } from "./theme-context";
14
14
  import { Tabs, Tab, TabList } from "./primitives/Tabs";
@@ -40,6 +40,8 @@ export interface ReviewRailProps {
40
40
  onSelectCard: (id: string) => void;
41
41
  onActivateCard: (id: string) => void;
42
42
  onSubmitRequest: () => void;
43
+ /** Publish the plan as a share; omitted for non-owners (hides the button). */
44
+ onShareRequest?: () => void;
43
45
  /** Rail column width; the app derives it from the persisted review layout. */
44
46
  width?: number;
45
47
  /**
@@ -68,6 +70,7 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
68
70
  onSelectCard,
69
71
  onActivateCard,
70
72
  onSubmitRequest,
73
+ onShareRequest,
71
74
  width = 34,
72
75
  onCollapse,
73
76
  theme,
@@ -76,6 +79,9 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
76
79
  ): React.ReactNode {
77
80
  const tokens = useComponentTheme(theme);
78
81
  const scrollRef = useRef<ScrollBoxRenderable | null>(null);
82
+ // addressed annotations leave the card list; a dim count keeps them honest
83
+ const openAnnotations = session.annotations.filter((annotation) => !isAddressed(annotation));
84
+ const addressedCount = session.annotations.length - openAnnotations.length;
79
85
 
80
86
  useImperativeHandle(handleRef, () => ({
81
87
  revealCard: (annotationId: string): void => {
@@ -101,11 +107,12 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
101
107
  ) : (
102
108
  <>
103
109
  {session.workingCopy !== undefined ? <text fg={tokens.textDim}>± plan edits → one diff</text> : null}
104
- {session.annotations.length === 0 ? (
105
- <text fg={tokens.textDim}>no annotations yet</text>
110
+ {addressedCount > 0 ? <text fg={tokens.textDim}>✓ {addressedCount} addressed by revision</text> : null}
111
+ {openAnnotations.length === 0 ? (
112
+ <text fg={tokens.textDim}>{session.annotations.length === 0 ? "no annotations yet" : "all annotations addressed"}</text>
106
113
  ) : (
107
114
  <scrollbox ref={scrollRef} style={{ flexGrow: 1 }} focused={false}>
108
- {session.annotations.map((annotation) => (
115
+ {openAnnotations.map((annotation) => (
109
116
  <AnnotationCard
110
117
  key={annotation.id}
111
118
  id={`annotation-card-${annotation.id}`}
@@ -145,6 +152,11 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
145
152
  {`Submit review (${pendingCount}) ⏎`}
146
153
  </Button>
147
154
  )}
155
+ {onShareRequest && session.status !== "resolved" ? (
156
+ <Button variant="plain" onPress={onShareRequest} theme={theme}>
157
+ {"Share plan ⇧S"}
158
+ </Button>
159
+ ) : null}
148
160
  </>
149
161
  )}
150
162
  {/* the collapse chevron: muted, left-bound, one column from the divider.
@@ -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>();
@@ -8,20 +8,28 @@ import { DARK } from "./theme";
8
8
 
9
9
  describe("loadConfig", () => {
10
10
  test("defaults when no file exists", () => {
11
+ // Act
11
12
  const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
13
+
14
+ // Assert
12
15
  expect(config.keys["comment"]).toEqual(["c"]);
13
16
  expect(config.theme.accent).toBe(DARK.accent);
14
17
  });
15
18
 
16
19
  test("user config rebinds actions and overrides theme tokens", () => {
20
+ // Arrange
17
21
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-"));
18
22
  const path = join(dir, "config.toml");
19
23
  writeFileSync(
20
24
  path,
21
25
  `[keys]\ncomment = "a"\nsubmit = ["return", "S"]\n\n[theme]\naccent = "#ff0000"\n`,
22
26
  );
27
+
23
28
  try {
29
+ // Act
24
30
  const config = loadConfig({ userConfigPath: path });
31
+
32
+ // Assert
25
33
  expect(config.keys["comment"]).toEqual(["a"]);
26
34
  expect(config.keys["submit"]).toEqual(["return", "S"]);
27
35
  expect(config.keys["cut"]).toEqual(["x"]); // untouched defaults survive
@@ -33,14 +41,19 @@ describe("loadConfig", () => {
33
41
  });
34
42
 
35
43
  test("repo config layers over user config", () => {
44
+ // Arrange
36
45
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg2-"));
37
46
  const user = join(dir, "user.toml");
38
47
  const repoRoot = join(dir, "repo");
39
48
  writeFileSync(user, `[keys]\ncomment = "a"\n`);
40
49
  Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
41
50
  writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[keys]\ncomment = "z"\n`);
51
+
42
52
  try {
53
+ // Act
43
54
  const config = loadConfig({ userConfigPath: user, repoRoot });
55
+
56
+ // Assert
44
57
  expect(config.keys["comment"]).toEqual(["z"]);
45
58
  } finally {
46
59
  rmSync(dir, { recursive: true, force: true });
@@ -48,11 +61,16 @@ describe("loadConfig", () => {
48
61
  });
49
62
 
50
63
  test("broken config never blocks a review", () => {
64
+ // Arrange
51
65
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg3-"));
52
66
  const path = join(dir, "config.toml");
53
67
  writeFileSync(path, "not [valid toml");
68
+
54
69
  try {
70
+ // Act
55
71
  const config = loadConfig({ userConfigPath: path });
72
+
73
+ // Assert
56
74
  expect(config.keys["comment"]).toEqual(["c"]);
57
75
  } finally {
58
76
  rmSync(dir, { recursive: true, force: true });
@@ -60,11 +78,16 @@ describe("loadConfig", () => {
60
78
  });
61
79
 
62
80
  test("[ui] parses auto_close and the editor override", () => {
81
+ // Arrange
63
82
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg4-"));
64
83
  const path = join(dir, "config.toml");
65
84
  writeFileSync(path, `[ui]\nauto_close = 3\neditor = "code --wait"\n`);
85
+
66
86
  try {
87
+ // Act
67
88
  const config = loadConfig({ userConfigPath: path });
89
+
90
+ // Assert
68
91
  expect(config.ui.autoClose).toBe(3);
69
92
  expect(config.ui.editor).toBe("code --wait");
70
93
  } finally {
@@ -73,10 +96,13 @@ describe("loadConfig", () => {
73
96
  });
74
97
 
75
98
  test("[ui] editor defaults to undefined and ignores a blank value", () => {
99
+ // Arrange
76
100
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg5-"));
77
101
  const path = join(dir, "config.toml");
78
102
  writeFileSync(path, `[ui]\neditor = " "\n`);
103
+
79
104
  try {
105
+ // Assert
80
106
  expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.editor).toBeUndefined();
81
107
  expect(loadConfig({ userConfigPath: path }).ui.editor).toBeUndefined();
82
108
  } finally {
@@ -85,17 +111,25 @@ describe("loadConfig", () => {
85
111
  });
86
112
 
87
113
  test("[ui] review panel defaults to an expanded rail at the default width", () => {
114
+ // Act
88
115
  const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
116
+
117
+ // Assert
89
118
  expect(config.ui.reviewState).toBe("expanded");
90
119
  expect(config.ui.reviewWidth).toBe(REVIEW_DEFAULT_WIDTH);
91
120
  });
92
121
 
93
122
  test("[ui] parses review_width (clamped) and review_state", () => {
123
+ // Arrange
94
124
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg6-"));
95
125
  const path = join(dir, "config.toml");
96
126
  writeFileSync(path, `[ui]\nreview_width = 999\nreview_state = "compact"\n`);
127
+
97
128
  try {
129
+ // Act
98
130
  const config = loadConfig({ userConfigPath: path });
131
+
132
+ // Assert
99
133
  expect(config.ui.reviewWidth).toBe(REVIEW_MAX_WIDTH); // out-of-range width clamps
100
134
  expect(config.ui.reviewState).toBe("compact");
101
135
  } finally {
@@ -104,11 +138,16 @@ describe("loadConfig", () => {
104
138
  });
105
139
 
106
140
  test("[ui] ignores an unknown review_state and a non-numeric width", () => {
141
+ // Arrange
107
142
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg7-"));
108
143
  const path = join(dir, "config.toml");
109
144
  writeFileSync(path, `[ui]\nreview_width = "wide"\nreview_state = "sideways"\n`);
145
+
110
146
  try {
147
+ // Act
111
148
  const config = loadConfig({ userConfigPath: path });
149
+
150
+ // Assert
112
151
  expect(config.ui.reviewWidth).toBe(REVIEW_DEFAULT_WIDTH);
113
152
  expect(config.ui.reviewState).toBe("expanded");
114
153
  } finally {
@@ -117,16 +156,25 @@ describe("loadConfig", () => {
117
156
  });
118
157
 
119
158
  test("persistReviewWidth and persistReviewState round-trip through the config file", () => {
159
+ // Arrange
120
160
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg8-"));
121
161
  const path = join(dir, "config.toml");
162
+
122
163
  try {
164
+ // Act
123
165
  persistReviewWidth(42, path);
124
166
  persistReviewState("hidden", path);
167
+
168
+ // Assert
125
169
  const config = loadConfig({ userConfigPath: path });
126
170
  expect(config.ui.reviewWidth).toBe(42);
127
171
  expect(config.ui.reviewState).toBe("hidden");
172
+
128
173
  // a second write replaces the key in place rather than appending a duplicate
174
+ // Act
129
175
  persistReviewWidth(30, path);
176
+
177
+ // Assert
130
178
  expect(loadConfig({ userConfigPath: path }).ui.reviewWidth).toBe(30);
131
179
  } finally {
132
180
  rmSync(dir, { recursive: true, force: true });
@@ -136,7 +184,10 @@ describe("loadConfig", () => {
136
184
 
137
185
  describe("integrations.obsidian config", () => {
138
186
  test("defaults when the section is absent", () => {
187
+ // Act
139
188
  const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
189
+
190
+ // Assert
140
191
  expect(config.integrations.obsidian.vault).toBeUndefined();
141
192
  expect(config.integrations.obsidian.folder).toBe("cueloop");
142
193
  expect(config.integrations.obsidian.filenameFormat).toBe("{YYYY}-{MM}-{DD} - {title}");
@@ -145,14 +196,19 @@ describe("integrations.obsidian config", () => {
145
196
  });
146
197
 
147
198
  test("user config sets the section; invalid enum values are ignored", () => {
199
+ // Arrange
148
200
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs-"));
149
201
  const path = join(dir, "config.toml");
150
202
  writeFileSync(
151
203
  path,
152
204
  `[integrations.obsidian]\nvault = "/notes/vault"\nfolder = "plans"\nexportOn = "approve"\nseparator = "comma"\n`,
153
205
  );
206
+
154
207
  try {
208
+ // Act
155
209
  const config = loadConfig({ userConfigPath: path });
210
+
211
+ // Assert
156
212
  expect(config.integrations.obsidian.vault).toBe("/notes/vault");
157
213
  expect(config.integrations.obsidian.folder).toBe("plans");
158
214
  expect(config.integrations.obsidian.exportOn).toBe("approve");
@@ -164,14 +220,19 @@ describe("integrations.obsidian config", () => {
164
220
  });
165
221
 
166
222
  test("repo config layers over user config for the section", () => {
223
+ // Arrange
167
224
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs2-"));
168
225
  const user = join(dir, "user.toml");
169
226
  const repoRoot = join(dir, "repo");
170
227
  writeFileSync(user, `[integrations.obsidian]\nvault = "/user/vault"\nexportOn = "resolve"\n`);
171
228
  Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
172
229
  writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[integrations.obsidian]\nfolder = "repo-plans"\n`);
230
+
173
231
  try {
232
+ // Act
174
233
  const config = loadConfig({ userConfigPath: user, repoRoot });
234
+
235
+ // Assert
175
236
  expect(config.integrations.obsidian.vault).toBe("/user/vault"); // user layer survives
176
237
  expect(config.integrations.obsidian.exportOn).toBe("resolve");
177
238
  expect(config.integrations.obsidian.folder).toBe("repo-plans"); // repo layer wins
@@ -183,6 +244,7 @@ describe("integrations.obsidian config", () => {
183
244
 
184
245
  describe("actionFor", () => {
185
246
  test("resolves keys and shifted keys to actions", () => {
247
+ // Assert
186
248
  expect(actionFor(DEFAULT_KEYS, "j", false)).toBe("down");
187
249
  expect(actionFor(DEFAULT_KEYS, "g", true)).toBe("bottom"); // G
188
250
  expect(actionFor(DEFAULT_KEYS, "return", false)).toBe("submit");
package/src/config.ts CHANGED
@@ -52,6 +52,7 @@ export const DEFAULT_KEYS: Record<string, string[]> = {
52
52
  prev_annotation: ["p"],
53
53
  delete_annotation: ["backspace"],
54
54
  submit: ["return", "enter"],
55
+ share: ["S"],
55
56
  quit: ["q"],
56
57
  walk: ["w"],
57
58
  review_cycle: ["b"],
@@ -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
@@ -1,2 +1,4 @@
1
1
  export { runClient, type RunClientOptions } from "./run";
2
2
  export { serveClient, type ServeHandle, type ServeOptions } from "./serve";
3
+ export { App, type AppProps } from "./App";
4
+ export { publishShare, type ShareResult, type ShareTarget } from "./share";
@@ -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
  });