@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.
Files changed (47) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +29 -5
  3. package/src/App.diff.test.tsx +20 -0
  4. package/src/App.export.test.tsx +12 -8
  5. package/src/App.plan-review.test.tsx +182 -7
  6. package/src/App.readonly.test.tsx +19 -5
  7. package/src/App.rendering.test.tsx +21 -2
  8. package/src/App.review-panel.test.tsx +120 -0
  9. package/src/App.submit-confirm.test.tsx +57 -6
  10. package/src/App.test.tsx +62 -5
  11. package/src/App.tsx +147 -185
  12. package/src/App.walk.test.tsx +63 -8
  13. package/src/clipboard.test.ts +23 -0
  14. package/src/clipboard.ts +26 -0
  15. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  16. package/src/components/AnnotationCard.tsx +66 -13
  17. package/src/components/ReviewPanel.stories.tsx +86 -0
  18. package/src/components/ReviewPanel.tsx +116 -0
  19. package/src/components/ReviewRail.tsx +30 -5
  20. package/src/components/__snapshots__/stories.test.tsx.snap +140 -0
  21. package/src/components/primitives/Card.tsx +2 -1
  22. package/src/components/primitives/Dialog.tsx +2 -1
  23. package/src/components/primitives/frame.test.ts +12 -0
  24. package/src/components/primitives/frame.ts +14 -0
  25. package/src/components/stories-app.tsx +2 -1
  26. package/src/components/stories.test.tsx +5 -0
  27. package/src/config.test.ts +113 -1
  28. package/src/config.ts +58 -22
  29. package/src/editor.test.ts +24 -0
  30. package/src/herdr.test.ts +4 -0
  31. package/src/index.ts +2 -0
  32. package/src/integrations.test.ts +12 -0
  33. package/src/intent-dispatch.test.ts +213 -0
  34. package/src/intent-dispatch.ts +270 -0
  35. package/src/key-bindings.test.ts +16 -5
  36. package/src/keymap.test.ts +141 -9
  37. package/src/keymap.ts +54 -7
  38. package/src/review-panel.test.ts +111 -0
  39. package/src/review-panel.ts +76 -0
  40. package/src/serve.test.ts +7 -0
  41. package/src/session-controller.ts +39 -4
  42. package/src/share.ts +47 -0
  43. package/src/view-diff.test.ts +11 -0
  44. package/src/view-diff.ts +6 -6
  45. package/src/view-plan.test.ts +81 -0
  46. package/src/view-plan.ts +3 -0
  47. 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: "rounded",
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: "rounded",
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>();
@@ -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, [integrations.obsidian]
5
- * notes-vault export.
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
- ui: { autoClose: AutoClose; editor?: string };
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 { auto_close?: unknown; editor?: unknown } | undefined;
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
- * Persist the auto-close choice into the user config file. Line-level TOML
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
- const rendered = value === "off" ? '"off"' : String(value);
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
- if (/^\s*auto_close\s*=/m.test(text)) {
145
- text = text.replace(/^(\s*)auto_close\s*=.*$/m, `$1auto_close = ${rendered}`);
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]\nauto_close = ${rendered}`);
168
+ text = text.replace(/^\[ui\]\s*$/m, `[ui]\n${key} = ${rendered}`);
148
169
  } else {
149
- text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[ui]\nauto_close = ${rendered}\n`;
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
+ }
@@ -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
  });