@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.
Files changed (92) hide show
  1. package/package.json +17 -11
  2. package/src/App.autoclose.test.tsx +109 -0
  3. package/src/App.diff.test.tsx +5 -18
  4. package/src/App.export.test.tsx +8 -13
  5. package/src/App.plan-review.test.tsx +293 -0
  6. package/src/App.readonly.test.tsx +6 -21
  7. package/src/App.rendering.test.tsx +104 -0
  8. package/src/App.review-panel.test.tsx +97 -0
  9. package/src/App.submit-confirm.test.tsx +165 -0
  10. package/src/App.test.tsx +20 -58
  11. package/src/App.tsx +0 -0
  12. package/src/App.walk.test.tsx +208 -0
  13. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  14. package/src/components/AnnotationCard.stories.tsx +75 -0
  15. package/src/components/AnnotationCard.tsx +189 -0
  16. package/src/components/Breadcrumb.stories.tsx +19 -0
  17. package/src/components/Breadcrumb.tsx +38 -0
  18. package/src/components/CodeBlock.stories.tsx +25 -0
  19. package/src/components/CodeBlock.tsx +68 -0
  20. package/src/components/CompletionOverlay.stories.tsx +23 -0
  21. package/src/components/CompletionOverlay.tsx +49 -0
  22. package/src/components/ComposeBar.stories.tsx +18 -0
  23. package/src/components/ComposeBar.tsx +54 -0
  24. package/src/components/ConfirmCard.stories.tsx +37 -0
  25. package/src/components/ConfirmCard.tsx +117 -0
  26. package/src/components/DiffSheet.stories.tsx +44 -0
  27. package/src/components/DiffSheet.tsx +243 -0
  28. package/src/components/InboxList.stories.tsx +18 -0
  29. package/src/components/InboxList.tsx +42 -0
  30. package/src/components/PlanSheet.stories.tsx +81 -0
  31. package/src/components/PlanSheet.tsx +309 -0
  32. package/src/components/ReviewPanel.stories.tsx +86 -0
  33. package/src/components/ReviewPanel.tsx +116 -0
  34. package/src/components/ReviewRail.stories.tsx +103 -0
  35. package/src/components/ReviewRail.tsx +171 -0
  36. package/src/components/SettingsDialog.stories.tsx +63 -0
  37. package/src/components/SettingsDialog.tsx +132 -0
  38. package/src/components/SettingsRows.stories.tsx +18 -0
  39. package/src/components/SettingsRows.tsx +92 -0
  40. package/src/components/WalkWizard.stories.tsx +89 -0
  41. package/src/components/WalkWizard.tsx +146 -0
  42. package/src/components/__snapshots__/stories.test.tsx.snap +1459 -0
  43. package/src/components/primitives/Button.stories.tsx +32 -0
  44. package/src/components/primitives/Button.tsx +44 -0
  45. package/src/components/primitives/Card.stories.tsx +25 -0
  46. package/src/components/primitives/Card.tsx +59 -0
  47. package/src/components/primitives/Dialog.stories.tsx +24 -0
  48. package/src/components/primitives/Dialog.tsx +53 -0
  49. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  50. package/src/components/primitives/StatusBar.tsx +22 -0
  51. package/src/components/primitives/Tabs.stories.tsx +29 -0
  52. package/src/components/primitives/Tabs.tsx +97 -0
  53. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  54. package/src/components/primitives/Toolbar.tsx +14 -0
  55. package/src/components/primitives/frame.test.ts +12 -0
  56. package/src/components/primitives/frame.ts +14 -0
  57. package/src/components/stories-app.tsx +95 -0
  58. package/src/components/stories.test.tsx +51 -0
  59. package/src/components/story-fixtures.ts +90 -0
  60. package/src/components/story.ts +66 -0
  61. package/src/components/syntax-highlight.ts +57 -0
  62. package/src/components/theme-context.stories.tsx +41 -0
  63. package/src/components/theme-context.tsx +30 -0
  64. package/src/components/truncate-text.ts +6 -0
  65. package/src/config.test.ts +105 -30
  66. package/src/config.ts +91 -16
  67. package/src/editor.test.ts +110 -0
  68. package/src/editor.ts +101 -13
  69. package/src/herdr.test.ts +37 -0
  70. package/src/herdr.ts +21 -0
  71. package/src/integrations.test.ts +29 -0
  72. package/src/integrations.ts +28 -0
  73. package/src/key-bindings.test.ts +120 -0
  74. package/src/key-bindings.ts +353 -0
  75. package/src/keymap.test.ts +347 -0
  76. package/src/keymap.ts +229 -0
  77. package/src/review-panel.test.ts +111 -0
  78. package/src/review-panel.ts +76 -0
  79. package/src/run.ts +9 -5
  80. package/src/serve.test.ts +6 -6
  81. package/src/serve.ts +7 -7
  82. package/src/session-controller.ts +512 -0
  83. package/src/test-support.ts +87 -0
  84. package/src/theme.ts +23 -1
  85. package/src/view-diff.test.ts +8 -8
  86. package/src/view-diff.ts +22 -22
  87. package/src/view-plan.test.ts +179 -0
  88. package/src/view-plan.ts +362 -0
  89. package/src/walk.test.ts +110 -0
  90. package/src/walk.ts +65 -0
  91. package/src/view.test.ts +0 -157
  92. 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
+ }
@@ -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 c = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
11
- expect(c.keys["comment"]).toEqual(["c"]);
12
- expect(c.theme.accent).toBe(DARK.accent);
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 c = loadConfig({ userConfigPath: path });
24
- expect(c.keys["comment"]).toEqual(["a"]);
25
- expect(c.keys["submit"]).toEqual(["return", "S"]);
26
- expect(c.keys["cut"]).toEqual(["x"]); // untouched defaults survive
27
- expect(c.theme.accent).toBe("#ff0000");
28
- expect(c.theme.green).toBe(DARK.green);
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 c = loadConfig({ userConfigPath: user, repoRoot });
43
- expect(c.keys["comment"]).toEqual(["z"]);
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 c = loadConfig({ userConfigPath: path });
55
- expect(c.keys["comment"]).toEqual(["c"]);
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 c = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
65
- expect(c.integrations.obsidian.vault).toBeUndefined();
66
- expect(c.integrations.obsidian.folder).toBe("cueloop");
67
- expect(c.integrations.obsidian.filenameFormat).toBe("{YYYY}-{MM}-{DD} - {title}");
68
- expect(c.integrations.obsidian.separator).toBe("space");
69
- expect(c.integrations.obsidian.exportOn).toBe("manual");
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 c = loadConfig({ userConfigPath: path });
81
- expect(c.integrations.obsidian.vault).toBe("/notes/vault");
82
- expect(c.integrations.obsidian.folder).toBe("plans");
83
- expect(c.integrations.obsidian.exportOn).toBe("approve");
84
- expect(c.integrations.obsidian.separator).toBe("space"); // invalid value falls back
85
- expect(c.keys["comment"]).toEqual(["c"]); // other sections untouched
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 c = loadConfig({ userConfigPath: user, repoRoot });
100
- expect(c.integrations.obsidian.vault).toBe("/user/vault"); // user layer survives
101
- expect(c.integrations.obsidian.exportOn).toBe("resolve");
102
- expect(c.integrations.obsidian.folder).toBe("repo-plans"); // repo layer wins
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 (#20): built-in defaults → user config → trusted repo
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, [integrations.obsidian] 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.
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 o = out.integrations.obsidian;
74
- if (typeof obsidian["vault"] === "string") o.vault = obsidian["vault"];
75
- if (typeof obsidian["folder"] === "string") o.folder = obsidian["folder"];
76
- if (typeof obsidian["filenameFormat"] === "string") o.filenameFormat = obsidian["filenameFormat"];
77
- const sep = obsidian["separator"];
78
- if (sep === "space" || sep === "dash" || sep === "underscore") o.separator = sep;
79
- const on = obsidian["exportOn"];
80
- if (on === "approve" || on === "resolve" || on === "manual") o.exportOn = on;
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(opts: { repoRoot?: string; userConfigPath?: string } = {}): CueloopConfig {
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
- opts.userConfigPath ??
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
+ }