@cueloop/client 0.1.0-alpha.2 → 0.1.0-alpha.20

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 (84) 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 +232 -0
  6. package/src/App.readonly.test.tsx +6 -21
  7. package/src/App.rendering.test.tsx +100 -0
  8. package/src/App.submit-confirm.test.tsx +165 -0
  9. package/src/App.test.tsx +20 -58
  10. package/src/App.tsx +0 -0
  11. package/src/App.walk.test.tsx +208 -0
  12. package/src/components/AnnotationCard.stories.tsx +75 -0
  13. package/src/components/AnnotationCard.tsx +136 -0
  14. package/src/components/Breadcrumb.stories.tsx +19 -0
  15. package/src/components/Breadcrumb.tsx +38 -0
  16. package/src/components/CodeBlock.stories.tsx +25 -0
  17. package/src/components/CodeBlock.tsx +68 -0
  18. package/src/components/CompletionOverlay.stories.tsx +23 -0
  19. package/src/components/CompletionOverlay.tsx +49 -0
  20. package/src/components/ComposeBar.stories.tsx +18 -0
  21. package/src/components/ComposeBar.tsx +54 -0
  22. package/src/components/ConfirmCard.stories.tsx +37 -0
  23. package/src/components/ConfirmCard.tsx +117 -0
  24. package/src/components/DiffSheet.stories.tsx +44 -0
  25. package/src/components/DiffSheet.tsx +243 -0
  26. package/src/components/InboxList.stories.tsx +18 -0
  27. package/src/components/InboxList.tsx +42 -0
  28. package/src/components/PlanSheet.stories.tsx +81 -0
  29. package/src/components/PlanSheet.tsx +309 -0
  30. package/src/components/ReviewRail.stories.tsx +103 -0
  31. package/src/components/ReviewRail.tsx +158 -0
  32. package/src/components/SettingsDialog.stories.tsx +63 -0
  33. package/src/components/SettingsDialog.tsx +132 -0
  34. package/src/components/SettingsRows.stories.tsx +18 -0
  35. package/src/components/SettingsRows.tsx +92 -0
  36. package/src/components/WalkWizard.stories.tsx +89 -0
  37. package/src/components/WalkWizard.tsx +146 -0
  38. package/src/components/__snapshots__/stories.test.tsx.snap +1319 -0
  39. package/src/components/primitives/Button.stories.tsx +32 -0
  40. package/src/components/primitives/Button.tsx +44 -0
  41. package/src/components/primitives/Card.stories.tsx +25 -0
  42. package/src/components/primitives/Card.tsx +58 -0
  43. package/src/components/primitives/Dialog.stories.tsx +24 -0
  44. package/src/components/primitives/Dialog.tsx +52 -0
  45. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  46. package/src/components/primitives/StatusBar.tsx +22 -0
  47. package/src/components/primitives/Tabs.stories.tsx +29 -0
  48. package/src/components/primitives/Tabs.tsx +97 -0
  49. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  50. package/src/components/primitives/Toolbar.tsx +14 -0
  51. package/src/components/stories-app.tsx +94 -0
  52. package/src/components/stories.test.tsx +51 -0
  53. package/src/components/story-fixtures.ts +90 -0
  54. package/src/components/story.ts +66 -0
  55. package/src/components/syntax-highlight.ts +57 -0
  56. package/src/components/theme-context.stories.tsx +41 -0
  57. package/src/components/theme-context.tsx +30 -0
  58. package/src/components/truncate-text.ts +6 -0
  59. package/src/config.test.ts +54 -29
  60. package/src/config.ts +53 -13
  61. package/src/editor.test.ts +110 -0
  62. package/src/editor.ts +101 -13
  63. package/src/herdr.test.ts +37 -0
  64. package/src/herdr.ts +21 -0
  65. package/src/integrations.test.ts +29 -0
  66. package/src/integrations.ts +28 -0
  67. package/src/key-bindings.test.ts +120 -0
  68. package/src/key-bindings.ts +353 -0
  69. package/src/keymap.test.ts +307 -0
  70. package/src/keymap.ts +204 -0
  71. package/src/run.ts +9 -5
  72. package/src/serve.test.ts +6 -6
  73. package/src/serve.ts +7 -7
  74. package/src/session-controller.ts +496 -0
  75. package/src/test-support.ts +87 -0
  76. package/src/theme.ts +23 -1
  77. package/src/view-diff.test.ts +8 -8
  78. package/src/view-diff.ts +22 -22
  79. package/src/view-plan.test.ts +179 -0
  80. package/src/view-plan.ts +362 -0
  81. package/src/walk.test.ts +110 -0
  82. package/src/walk.ts +65 -0
  83. package/src/view.test.ts +0 -157
  84. package/src/view.ts +0 -0
@@ -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
+ }
@@ -7,9 +7,9 @@ import { DARK } from "./theme";
7
7
 
8
8
  describe("loadConfig", () => {
9
9
  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);
10
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
11
+ expect(config.keys["comment"]).toEqual(["c"]);
12
+ expect(config.theme.accent).toBe(DARK.accent);
13
13
  });
14
14
 
15
15
  test("user config rebinds actions and overrides theme tokens", () => {
@@ -20,12 +20,12 @@ describe("loadConfig", () => {
20
20
  `[keys]\ncomment = "a"\nsubmit = ["return", "S"]\n\n[theme]\naccent = "#ff0000"\n`,
21
21
  );
22
22
  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);
23
+ const config = loadConfig({ userConfigPath: path });
24
+ expect(config.keys["comment"]).toEqual(["a"]);
25
+ expect(config.keys["submit"]).toEqual(["return", "S"]);
26
+ expect(config.keys["cut"]).toEqual(["x"]); // untouched defaults survive
27
+ expect(config.theme.accent).toBe("#ff0000");
28
+ expect(config.theme.green).toBe(DARK.green);
29
29
  } finally {
30
30
  rmSync(dir, { recursive: true, force: true });
31
31
  }
@@ -39,8 +39,8 @@ describe("loadConfig", () => {
39
39
  Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
40
40
  writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[keys]\ncomment = "z"\n`);
41
41
  try {
42
- const c = loadConfig({ userConfigPath: user, repoRoot });
43
- expect(c.keys["comment"]).toEqual(["z"]);
42
+ const config = loadConfig({ userConfigPath: user, repoRoot });
43
+ expect(config.keys["comment"]).toEqual(["z"]);
44
44
  } finally {
45
45
  rmSync(dir, { recursive: true, force: true });
46
46
  }
@@ -51,8 +51,33 @@ describe("loadConfig", () => {
51
51
  const path = join(dir, "config.toml");
52
52
  writeFileSync(path, "not [valid toml");
53
53
  try {
54
- const c = loadConfig({ userConfigPath: path });
55
- expect(c.keys["comment"]).toEqual(["c"]);
54
+ const config = loadConfig({ userConfigPath: path });
55
+ expect(config.keys["comment"]).toEqual(["c"]);
56
+ } finally {
57
+ rmSync(dir, { recursive: true, force: true });
58
+ }
59
+ });
60
+
61
+ test("[ui] parses auto_close and the editor override", () => {
62
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg4-"));
63
+ const path = join(dir, "config.toml");
64
+ writeFileSync(path, `[ui]\nauto_close = 3\neditor = "code --wait"\n`);
65
+ try {
66
+ const config = loadConfig({ userConfigPath: path });
67
+ expect(config.ui.autoClose).toBe(3);
68
+ expect(config.ui.editor).toBe("code --wait");
69
+ } finally {
70
+ rmSync(dir, { recursive: true, force: true });
71
+ }
72
+ });
73
+
74
+ test("[ui] editor defaults to undefined and ignores a blank value", () => {
75
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg5-"));
76
+ const path = join(dir, "config.toml");
77
+ writeFileSync(path, `[ui]\neditor = " "\n`);
78
+ try {
79
+ expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.editor).toBeUndefined();
80
+ expect(loadConfig({ userConfigPath: path }).ui.editor).toBeUndefined();
56
81
  } finally {
57
82
  rmSync(dir, { recursive: true, force: true });
58
83
  }
@@ -61,12 +86,12 @@ describe("loadConfig", () => {
61
86
 
62
87
  describe("integrations.obsidian config", () => {
63
88
  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");
89
+ const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
90
+ expect(config.integrations.obsidian.vault).toBeUndefined();
91
+ expect(config.integrations.obsidian.folder).toBe("cueloop");
92
+ expect(config.integrations.obsidian.filenameFormat).toBe("{YYYY}-{MM}-{DD} - {title}");
93
+ expect(config.integrations.obsidian.separator).toBe("space");
94
+ expect(config.integrations.obsidian.exportOn).toBe("manual");
70
95
  });
71
96
 
72
97
  test("user config sets the section; invalid enum values are ignored", () => {
@@ -77,12 +102,12 @@ describe("integrations.obsidian config", () => {
77
102
  `[integrations.obsidian]\nvault = "/notes/vault"\nfolder = "plans"\nexportOn = "approve"\nseparator = "comma"\n`,
78
103
  );
79
104
  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
105
+ const config = loadConfig({ userConfigPath: path });
106
+ expect(config.integrations.obsidian.vault).toBe("/notes/vault");
107
+ expect(config.integrations.obsidian.folder).toBe("plans");
108
+ expect(config.integrations.obsidian.exportOn).toBe("approve");
109
+ expect(config.integrations.obsidian.separator).toBe("space"); // invalid value falls back
110
+ expect(config.keys["comment"]).toEqual(["c"]); // other sections untouched
86
111
  } finally {
87
112
  rmSync(dir, { recursive: true, force: true });
88
113
  }
@@ -96,10 +121,10 @@ describe("integrations.obsidian config", () => {
96
121
  Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
97
122
  writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[integrations.obsidian]\nfolder = "repo-plans"\n`);
98
123
  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
124
+ const config = loadConfig({ userConfigPath: user, repoRoot });
125
+ expect(config.integrations.obsidian.vault).toBe("/user/vault"); // user layer survives
126
+ expect(config.integrations.obsidian.exportOn).toBe("resolve");
127
+ expect(config.integrations.obsidian.folder).toBe("repo-plans"); // repo layer wins
103
128
  } finally {
104
129
  rmSync(dir, { recursive: true, force: true });
105
130
  }
package/src/config.ts CHANGED
@@ -1,13 +1,16 @@
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, [integrations.obsidian]
5
+ * notes-vault export.
5
6
  */
6
7
 
7
8
  import { existsSync, readFileSync } from "node:fs";
8
9
  import { homedir } from "node:os";
9
10
  import { join } from "node:path";
10
11
  import { OBSIDIAN_DEFAULTS, type ObsidianConfig } from "@cueloop/integration-obsidian";
12
+ import { writeFileSync, mkdirSync } from "node:fs";
13
+ import { dirname } from "node:path";
11
14
  import { DARK, type Theme } from "./theme";
12
15
 
13
16
  export interface KeymapConfig {
@@ -18,9 +21,13 @@ export interface IntegrationsConfig {
18
21
  obsidian: ObsidianConfig;
19
22
  }
20
23
 
24
+ /** Post-submit behavior: "off" prompts, 0 closes instantly, N counts down. */
25
+ export type AutoClose = "off" | number;
26
+
21
27
  export interface CueloopConfig {
22
28
  keys: Record<string, string[]>;
23
29
  theme: Theme;
30
+ ui: { autoClose: AutoClose; editor?: string };
24
31
  integrations: IntegrationsConfig;
25
32
  }
26
33
 
@@ -40,6 +47,7 @@ export const DEFAULT_KEYS: Record<string, string[]> = {
40
47
  delete_annotation: ["backspace"],
41
48
  submit: ["return", "enter"],
42
49
  quit: ["q"],
50
+ walk: ["w"],
43
51
  };
44
52
 
45
53
  function parseToml(text: string): Record<string, unknown> {
@@ -51,6 +59,7 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
51
59
  const out: CueloopConfig = {
52
60
  keys: { ...base.keys },
53
61
  theme: { ...base.theme },
62
+ ui: { ...base.ui },
54
63
  integrations: { obsidian: { ...base.integrations.obsidian } },
55
64
  };
56
65
  const keys = raw["keys"] as KeymapConfig | undefined;
@@ -59,6 +68,12 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
59
68
  out.keys[action] = Array.isArray(combo) ? combo : [combo];
60
69
  }
61
70
  }
71
+ const ui = raw["ui"] as { auto_close?: unknown; editor?: unknown } | undefined;
72
+ if (ui && ui.auto_close !== undefined) {
73
+ if (ui.auto_close === "off") out.ui.autoClose = "off";
74
+ else if (typeof ui.auto_close === "number" && ui.auto_close >= 0) out.ui.autoClose = ui.auto_close;
75
+ }
76
+ if (ui && typeof ui.editor === "string" && ui.editor.trim()) out.ui.editor = ui.editor.trim();
62
77
  const theme = raw["theme"] as Partial<Record<keyof Theme, string>> | undefined;
63
78
  if (theme) {
64
79
  for (const [token, value] of Object.entries(theme)) {
@@ -70,29 +85,30 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
70
85
  const integrations = raw["integrations"] as Record<string, unknown> | undefined;
71
86
  const obsidian = integrations?.["obsidian"] as Record<string, unknown> | undefined;
72
87
  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;
88
+ const obsidianConfig = out.integrations.obsidian;
89
+ if (typeof obsidian["vault"] === "string") obsidianConfig.vault = obsidian["vault"];
90
+ if (typeof obsidian["folder"] === "string") obsidianConfig.folder = obsidian["folder"];
91
+ if (typeof obsidian["filenameFormat"] === "string") obsidianConfig.filenameFormat = obsidian["filenameFormat"];
92
+ const separator = obsidian["separator"];
93
+ if (separator === "space" || separator === "dash" || separator === "underscore") obsidianConfig.separator = separator;
94
+ const exportOn = obsidian["exportOn"];
95
+ if (exportOn === "approve" || exportOn === "resolve" || exportOn === "manual") obsidianConfig.exportOn = exportOn;
81
96
  }
82
97
  return out;
83
98
  }
84
99
 
85
- export function loadConfig(opts: { repoRoot?: string; userConfigPath?: string } = {}): CueloopConfig {
100
+ export function loadConfig(options: { repoRoot?: string; userConfigPath?: string } = {}): CueloopConfig {
86
101
  let config: CueloopConfig = {
87
102
  keys: { ...DEFAULT_KEYS },
88
103
  theme: { ...DARK },
104
+ ui: { autoClose: "off" },
89
105
  integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
90
106
  };
91
107
  const userPath =
92
- opts.userConfigPath ??
108
+ options.userConfigPath ??
93
109
  process.env.CUELOOP_CONFIG ??
94
110
  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]) {
111
+ for (const path of [userPath, options.repoRoot ? join(options.repoRoot, ".cueloop", "config.toml") : undefined]) {
96
112
  if (!path || !existsSync(path)) continue;
97
113
  try {
98
114
  config = layer(config, parseToml(readFileSync(path, "utf8")));
@@ -111,3 +127,27 @@ export function actionFor(keys: Record<string, string[]>, name: string, shift: b
111
127
  }
112
128
  return undefined;
113
129
  }
130
+
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 =
138
+ userConfigPath ??
139
+ 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);
142
+ let text = "";
143
+ 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}`);
146
+ } else if (/^\[ui\]/m.test(text)) {
147
+ text = text.replace(/^\[ui\]\s*$/m, `[ui]\nauto_close = ${rendered}`);
148
+ } else {
149
+ text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[ui]\nauto_close = ${rendered}\n`;
150
+ }
151
+ mkdirSync(dirname(path), { recursive: true });
152
+ writeFileSync(path, text);
153
+ }
@@ -0,0 +1,110 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { editInEditor, resolveEditor, resolveEditorCommand } from "./editor";
6
+
7
+ describe("resolveEditor", () => {
8
+ test("a clean environment falls back to nano so editing always works", () => {
9
+ expect(resolveEditor(undefined, {})).toBe("nano");
10
+ });
11
+
12
+ test("the environment chain wins in order: CUELOOP_EDITOR, VISUAL, EDITOR", () => {
13
+ expect(resolveEditor(undefined, { EDITOR: "vim", VISUAL: "code", CUELOOP_EDITOR: "hx" })).toBe("hx");
14
+ expect(resolveEditor(undefined, { EDITOR: "vim", VISUAL: "code" })).toBe("code");
15
+ expect(resolveEditor(undefined, { EDITOR: "vim" })).toBe("vim");
16
+ });
17
+
18
+ test("the [ui] editor config overrides the environment", () => {
19
+ expect(resolveEditor("code --wait", { EDITOR: "vim" })).toBe("code --wait");
20
+ });
21
+ });
22
+
23
+ describe("resolveEditorCommand", () => {
24
+ test("a known GUI editor gets its wait flag appended and is trusted to wait", () => {
25
+ expect(resolveEditorCommand("code")).toEqual({ argv: ["code", "--wait"], waits: true });
26
+ expect(resolveEditorCommand("subl")).toEqual({ argv: ["subl", "--new-window", "--wait"], waits: true });
27
+ });
28
+
29
+ test("an existing wait flag is not duplicated", () => {
30
+ expect(resolveEditorCommand("code --wait").argv).toEqual(["code", "--wait"]);
31
+ });
32
+
33
+ test("a terminal editor is left alone and trusted to hold the tty", () => {
34
+ expect(resolveEditorCommand("vim")).toEqual({ argv: ["vim"], waits: true });
35
+ });
36
+
37
+ test("an unknown editor is neither flagged nor trusted to wait", () => {
38
+ expect(resolveEditorCommand("my-editor")).toEqual({ argv: ["my-editor"], waits: false });
39
+ });
40
+ });
41
+
42
+ describe("editInEditor", () => {
43
+ let dir: string;
44
+ beforeEach(() => {
45
+ dir = mkdtempSync(join(tmpdir(), "cueloop-edit-test-"));
46
+ });
47
+ afterEach(() => {
48
+ rmSync(dir, { recursive: true, force: true });
49
+ });
50
+
51
+ /** A runnable editor stub with a chosen base name and body. */
52
+ function stubEditor(name: string, body: string): string {
53
+ const script = join(dir, name);
54
+ writeFileSync(script, `#!/bin/sh\n${body}\n`);
55
+ chmodSync(script, 0o755);
56
+ return script;
57
+ }
58
+
59
+ const instant = (): (() => number) => {
60
+ let value = 0;
61
+ return () => (value += 5);
62
+ };
63
+
64
+ test("an editor that changes the file reports the new content", () => {
65
+ const result = editInEditor("old", "plan.md", { editor: stubEditor("writer.sh", `printf 'new' > "$1"`) });
66
+ expect(result).toEqual({ content: "new", changed: true });
67
+ });
68
+
69
+ test("a fast, unchanged return opens the gate; confirming re-reads the saved file", () => {
70
+ let askedLabel = "";
71
+ const result = editInEditor("keep", "plan.md", {
72
+ editor: stubEditor("gui-like.sh", "exit 0"),
73
+ now: instant(),
74
+ confirmSaved: (label, path) => {
75
+ askedLabel = label;
76
+ writeFileSync(path, "saved in the GUI");
77
+ return true;
78
+ },
79
+ });
80
+ expect(askedLabel).toContain("gui-like.sh");
81
+ expect(result).toEqual({ content: "saved in the GUI", changed: true });
82
+ });
83
+
84
+ test("declining the gate keeps the original content", () => {
85
+ const result = editInEditor("keep", "plan.md", {
86
+ editor: stubEditor("gui-like.sh", "exit 0"),
87
+ now: instant(),
88
+ confirmSaved: () => false,
89
+ });
90
+ expect(result).toEqual({ content: "keep", changed: false });
91
+ });
92
+
93
+ test("a terminal editor that makes no change never opens the gate", () => {
94
+ let asked = false;
95
+ const result = editInEditor("keep", "plan.md", {
96
+ editor: stubEditor("vi", "exit 0"),
97
+ now: instant(),
98
+ confirmSaved: () => {
99
+ asked = true;
100
+ return true;
101
+ },
102
+ });
103
+ expect(asked).toBeFalse();
104
+ expect(result).toEqual({ content: "keep", changed: false });
105
+ });
106
+
107
+ test("a nonzero editor exit throws", () => {
108
+ expect(() => editInEditor("x", "plan.md", { editor: stubEditor("fail.sh", "exit 3") })).toThrow("editor exited 3");
109
+ });
110
+ });