@cueloop/client 0.1.0-alpha.19 → 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.
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.19",
3
+ "version": "0.1.0-alpha.20",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.ts"
8
8
  },
9
9
  "dependencies": {
10
- "@cueloop/daemon": "0.1.0-alpha.19",
11
- "@cueloop/extension-api": "0.1.0-alpha.19",
12
- "@cueloop/integration-obsidian": "0.1.0-alpha.19",
13
- "@cueloop/schema": "0.1.0-alpha.19",
10
+ "@cueloop/daemon": "0.1.0-alpha.20",
11
+ "@cueloop/extension-api": "0.1.0-alpha.20",
12
+ "@cueloop/integration-obsidian": "0.1.0-alpha.20",
13
+ "@cueloop/schema": "0.1.0-alpha.20",
14
14
  "@opentui/core": "^0.5.1",
15
15
  "@opentui/keymap": "0.5.1",
16
16
  "@opentui/react": "^0.5.1",
@@ -142,7 +142,9 @@ describe("inline compose keeps the anchor painted", () => {
142
142
  const stored = server.core.sessionGet(session.id);
143
143
  expect(stored.annotations.length).toBe(1);
144
144
  expect(backgroundsOf(setup, stored.annotations[0]!.anchor.quote.slice(0, 20))).toContain(T.markCommentBg);
145
- });
145
+ // renderApp plus this many frame-waits grazes the 5s default on a loaded CI
146
+ // runner; the sibling walk resume test carries the same budget.
147
+ }, 15_000);
146
148
  });
147
149
 
148
150
  describe("the document selects, the rail edits", () => {
@@ -57,6 +57,31 @@ describe("loadConfig", () => {
57
57
  rmSync(dir, { recursive: true, force: true });
58
58
  }
59
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();
81
+ } finally {
82
+ rmSync(dir, { recursive: true, force: true });
83
+ }
84
+ });
60
85
  });
61
86
 
62
87
  describe("integrations.obsidian config", () => {
package/src/config.ts CHANGED
@@ -1,7 +1,8 @@
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, [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";
@@ -26,7 +27,7 @@ export type AutoClose = "off" | number;
26
27
  export interface CueloopConfig {
27
28
  keys: Record<string, string[]>;
28
29
  theme: Theme;
29
- ui: { autoClose: AutoClose };
30
+ ui: { autoClose: AutoClose; editor?: string };
30
31
  integrations: IntegrationsConfig;
31
32
  }
32
33
 
@@ -67,11 +68,12 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
67
68
  out.keys[action] = Array.isArray(combo) ? combo : [combo];
68
69
  }
69
70
  }
70
- const ui = raw["ui"] as { auto_close?: unknown } | undefined;
71
+ const ui = raw["ui"] as { auto_close?: unknown; editor?: unknown } | undefined;
71
72
  if (ui && ui.auto_close !== undefined) {
72
73
  if (ui.auto_close === "off") out.ui.autoClose = "off";
73
74
  else if (typeof ui.auto_close === "number" && ui.auto_close >= 0) out.ui.autoClose = ui.auto_close;
74
75
  }
76
+ if (ui && typeof ui.editor === "string" && ui.editor.trim()) out.ui.editor = ui.editor.trim();
75
77
  const theme = raw["theme"] as Partial<Record<keyof Theme, string>> | undefined;
76
78
  if (theme) {
77
79
  for (const [token, value] of Object.entries(theme)) {
@@ -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
+ });
package/src/editor.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  /**
2
- * File-level edit mode = the $EDITOR hand-off: write the working
3
- * copy to a temp file, run the user's editor on it, read the result back.
4
- * Tests point CUELOOP_EDITOR at a non-interactive command.
2
+ * File-level edit mode = the editor hand-off: write the working copy to a temp
3
+ * file, run the reviewer's editor on it, read the result back. Editing must
4
+ * work with zero configuration, so the resolution chain ends in a terminal
5
+ * fallback that is always present. A GUI editor launched without a wait flag
6
+ * forks and returns instantly, losing the edit silently; known GUI editors get
7
+ * their wait flag applied, and any editor that still returns right away with an
8
+ * untouched file drops to a confirm gate rather than discarding the work.
5
9
  */
6
10
 
7
11
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -13,20 +17,104 @@ export interface EditResult {
13
17
  changed: boolean;
14
18
  }
15
19
 
16
- export function editInEditor(content: string, filename = "plan.md"): EditResult {
17
- const editor = process.env.CUELOOP_EDITOR ?? process.env.VISUAL ?? process.env.EDITOR;
18
- if (!editor) throw new Error("no editor: set $EDITOR (or $CUELOOP_EDITOR)");
20
+ /** Present on every POSIX system, so a clean shell can still edit. */
21
+ const DEFAULT_EDITOR = "nano";
22
+
23
+ /** GUI editors fork and return immediately unless given their wait flag. */
24
+ const GUI_WAIT_FLAGS: Record<string, string[]> = {
25
+ code: ["--wait"],
26
+ "code-insiders": ["--wait"],
27
+ codium: ["--wait"],
28
+ cursor: ["--wait"],
29
+ windsurf: ["--wait"],
30
+ subl: ["--new-window", "--wait"],
31
+ sublime_text: ["--new-window", "--wait"],
32
+ zed: ["--wait"],
33
+ bbedit: ["--wait"],
34
+ mate: ["--wait"],
35
+ gedit: ["--wait"],
36
+ kate: ["--block"],
37
+ idea: ["--wait"],
38
+ webstorm: ["--wait"],
39
+ };
40
+
41
+ /** Editors that hold the terminal themselves - never GUI, never a wait gate. */
42
+ const TERMINAL_EDITORS = new Set([
43
+ "vi", "vim", "nvim", "nano", "pico", "emacs", "emacsclient", "micro", "hx", "helix", "kak", "ed", "joe", "mcedit",
44
+ ]);
45
+
46
+ export interface ResolvedEditor {
47
+ argv: string[];
48
+ /** True when this editor blocks the hand-off until the file is closed - a
49
+ * terminal editor, or a known GUI editor with its wait flag applied. */
50
+ waits: boolean;
51
+ }
52
+
53
+ /** The editor to run: [ui] editor, then the environment chain, then nano. */
54
+ export function resolveEditor(configuredEditor: string | undefined, env: Record<string, string | undefined> = process.env): string {
55
+ const candidates = [configuredEditor, env.CUELOOP_EDITOR, env.VISUAL, env.EDITOR];
56
+ return candidates.find((candidate) => candidate && candidate.trim())?.trim() ?? DEFAULT_EDITOR;
57
+ }
58
+
59
+ function editorBaseName(command: string): string {
60
+ return (command.split(/[\\/]/).pop() ?? command).toLowerCase();
61
+ }
62
+
63
+ /** Split the editor string and apply a GUI wait flag when we know one. */
64
+ export function resolveEditorCommand(rawEditor: string): ResolvedEditor {
65
+ const parts = rawEditor.trim().split(/\s+/);
66
+ const base = editorBaseName(parts[0] ?? "");
67
+ if (TERMINAL_EDITORS.has(base)) return { argv: parts, waits: true };
68
+ const waitFlags = GUI_WAIT_FLAGS[base];
69
+ if (waitFlags) {
70
+ const alreadyWaits = parts.some((part) => part === "--wait" || part === "-w" || part === "--block");
71
+ return { argv: alreadyWaits ? parts : [...parts, ...waitFlags], waits: true };
72
+ }
73
+ return { argv: parts, waits: false };
74
+ }
75
+
76
+ /** A fast return with an untouched file means the editor probably did not wait. */
77
+ const NO_WAIT_THRESHOLD_MS = 1000;
78
+
79
+ function suspectsNoWait(resolved: ResolvedEditor, elapsedMs: number, unchanged: boolean): boolean {
80
+ if (resolved.waits) return false; // holds the terminal or has a wait flag - trust the exit
81
+ if (!unchanged) return false; // edits landed, so it waited after all
82
+ return elapsedMs < NO_WAIT_THRESHOLD_MS;
83
+ }
84
+
85
+ export interface EditHandOff {
86
+ /** Editor from [ui] editor config; overrides the environment. */
87
+ editor?: string;
88
+ env?: Record<string, string | undefined>;
89
+ /** Confirm gate for a suspected no-wait return; true re-reads the file. */
90
+ confirmSaved?: (editorLabel: string, path: string) => boolean;
91
+ now?: () => number;
92
+ }
93
+
94
+ /** Prompt on the released terminal during the suspend window. */
95
+ function promptSaved(editorLabel: string, path: string): boolean {
96
+ const answer = prompt(
97
+ `${editorLabel} returned immediately and the plan is unchanged - a GUI editor needs its wait flag. Save and close ${path}, then press Enter to load your edits (or type n to skip):`,
98
+ );
99
+ return answer !== null && answer.trim().toLowerCase() !== "n";
100
+ }
101
+
102
+ export function editInEditor(content: string, filename = "plan.md", handOff: EditHandOff = {}): EditResult {
103
+ const env = handOff.env ?? process.env;
104
+ const now = handOff.now ?? Date.now;
105
+ const resolved = resolveEditorCommand(resolveEditor(handOff.editor, env));
19
106
  const dir = mkdtempSync(join(tmpdir(), "cueloop-edit-"));
20
107
  const path = join(dir, filename);
21
108
  writeFileSync(path, content);
22
109
  try {
23
- const proc = Bun.spawnSync([...editor.split(" "), path], {
24
- stdin: "inherit",
25
- stdout: "inherit",
26
- stderr: "inherit",
27
- });
28
- if (proc.exitCode !== 0) throw new Error(`editor exited ${proc.exitCode}`);
29
- const next = readFileSync(path, "utf8");
110
+ const startedAt = now();
111
+ const editorProcess = Bun.spawnSync([...resolved.argv, path], { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
112
+ if (editorProcess.exitCode !== 0) throw new Error(`editor exited ${editorProcess.exitCode}`);
113
+ let next = readFileSync(path, "utf8");
114
+ if (suspectsNoWait(resolved, now() - startedAt, next === content)) {
115
+ const confirm = handOff.confirmSaved ?? promptSaved;
116
+ if (confirm(resolved.argv[0] ?? "the editor", path)) next = readFileSync(path, "utf8");
117
+ }
30
118
  return { content: next, changed: next !== content };
31
119
  } finally {
32
120
  rmSync(dir, { recursive: true, force: true });
@@ -140,6 +140,7 @@ class Controller implements ReviewController {
140
140
  };
141
141
  private listeners = new Set<() => void>();
142
142
  private autoClose: AutoClose = "off";
143
+ private editor: string | undefined;
143
144
  private exporters: BundledExporter[] = [];
144
145
  private readonly clock: Clock;
145
146
  private countdown: TimerHandle | undefined;
@@ -205,6 +206,7 @@ class Controller implements ReviewController {
205
206
 
206
207
  applyConfig(config: CueloopConfig): void {
207
208
  this.autoClose = config.ui.autoClose;
209
+ this.editor = config.ui.editor;
208
210
  void loadBundledExporters(config.integrations).then((exporters) => {
209
211
  this.exporters = exporters;
210
212
  });
@@ -304,7 +306,7 @@ class Controller implements ReviewController {
304
306
  const session = this.snapshot.session;
305
307
  if (!session || session.status === "resolved") return;
306
308
  try {
307
- const result = editInEditor(this.working(), "plan.md");
309
+ const result = editInEditor(this.working(), "plan.md", { editor: this.editor });
308
310
  if (result.changed) {
309
311
  this.setWorkingCopy(result.content);
310
312
  this.reconcileAnnotations(session, result.content);