@cueloop/client 0.1.0-alpha.3 → 0.1.0-alpha.31
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 +17 -11
- package/src/App.autoclose.test.tsx +133 -0
- package/src/App.diff.test.tsx +27 -20
- package/src/App.export.test.tsx +19 -28
- package/src/App.plan-review.test.tsx +507 -0
- package/src/App.readonly.test.tsx +26 -27
- package/src/App.rendering.test.tsx +119 -0
- package/src/App.review-panel.test.tsx +123 -0
- package/src/App.submit-confirm.test.tsx +216 -0
- package/src/App.test.tsx +83 -64
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +265 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +32 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +75 -0
- package/src/components/AnnotationCard.tsx +208 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +38 -0
- package/src/components/CodeBlock.stories.tsx +25 -0
- package/src/components/CodeBlock.tsx +68 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +49 -0
- package/src/components/ComposeBar.stories.tsx +18 -0
- package/src/components/ComposeBar.tsx +54 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +117 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +56 -0
- package/src/components/DiffSheet.stories.tsx +44 -0
- package/src/components/DiffSheet.tsx +243 -0
- package/src/components/InboxList.stories.tsx +18 -0
- package/src/components/InboxList.tsx +55 -0
- package/src/components/PlanSheet.stories.tsx +81 -0
- package/src/components/PlanSheet.tsx +329 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +62 -0
- package/src/components/ReviewPanel.stories.tsx +87 -0
- package/src/components/ReviewPanel.tsx +116 -0
- package/src/components/ReviewRail.stories.tsx +107 -0
- package/src/components/ReviewRail.tsx +180 -0
- package/src/components/SettingsDialog.stories.tsx +63 -0
- package/src/components/SettingsDialog.tsx +132 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +92 -0
- package/src/components/WalkWizard.stories.tsx +89 -0
- package/src/components/WalkWizard.tsx +146 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1519 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +44 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +54 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +97 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/stories-app.tsx +95 -0
- package/src/components/stories.test.tsx +60 -0
- package/src/components/story-fixtures.ts +90 -0
- package/src/components/story.ts +66 -0
- package/src/components/syntax-highlight.ts +57 -0
- package/src/components/theme-context.stories.tsx +41 -0
- package/src/components/theme-context.tsx +30 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +196 -30
- package/src/config.ts +128 -16
- package/src/editor.test.ts +134 -0
- package/src/editor.ts +101 -13
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +21 -0
- package/src/index.ts +2 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +28 -0
- package/src/intent-dispatch.test.ts +308 -0
- package/src/intent-dispatch.ts +295 -0
- package/src/key-bindings.test.ts +142 -0
- package/src/key-bindings.ts +363 -0
- package/src/keymap.test.ts +477 -0
- package/src/keymap.ts +272 -0
- package/src/review-panel.test.ts +111 -0
- package/src/review-panel.ts +76 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +13 -6
- package/src/serve.ts +7 -7
- package/src/session-controller.share.test.ts +248 -0
- package/src/session-controller.ts +607 -0
- package/src/share.ts +80 -0
- package/src/test-support.ts +87 -0
- package/src/theme.ts +26 -3
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +22 -22
- package/src/view-plan.test.ts +260 -0
- package/src/view-plan.ts +365 -0
- package/src/walk.test.ts +116 -0
- package/src/walk.ts +65 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
package/src/config.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Layered TOML config
|
|
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, [
|
|
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,19 @@ 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 };
|
|
37
|
+
/** Planner-local author renames: identity id → display name ([authors] table). */
|
|
38
|
+
authors: Record<string, string>;
|
|
24
39
|
integrations: IntegrationsConfig;
|
|
25
40
|
}
|
|
26
41
|
|
|
@@ -38,8 +53,14 @@ export const DEFAULT_KEYS: Record<string, string[]> = {
|
|
|
38
53
|
next_annotation: ["n"],
|
|
39
54
|
prev_annotation: ["p"],
|
|
40
55
|
delete_annotation: ["backspace"],
|
|
56
|
+
rename: ["r"],
|
|
41
57
|
submit: ["return", "enter"],
|
|
58
|
+
share: ["S"],
|
|
42
59
|
quit: ["q"],
|
|
60
|
+
walk: ["w"],
|
|
61
|
+
review_cycle: ["b"],
|
|
62
|
+
review_wider: ["]"],
|
|
63
|
+
review_narrower: ["["],
|
|
43
64
|
};
|
|
44
65
|
|
|
45
66
|
function parseToml(text: string): Record<string, unknown> {
|
|
@@ -51,14 +72,36 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
|
|
|
51
72
|
const out: CueloopConfig = {
|
|
52
73
|
keys: { ...base.keys },
|
|
53
74
|
theme: { ...base.theme },
|
|
75
|
+
ui: { ...base.ui },
|
|
76
|
+
authors: { ...base.authors },
|
|
54
77
|
integrations: { obsidian: { ...base.integrations.obsidian } },
|
|
55
78
|
};
|
|
79
|
+
const authors = raw["authors"] as Record<string, unknown> | undefined;
|
|
80
|
+
if (authors) {
|
|
81
|
+
for (const [id, value] of Object.entries(authors)) {
|
|
82
|
+
if (typeof value === "string") out.authors[id] = value;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
56
85
|
const keys = raw["keys"] as KeymapConfig | undefined;
|
|
57
86
|
if (keys) {
|
|
58
87
|
for (const [action, combo] of Object.entries(keys)) {
|
|
59
88
|
out.keys[action] = Array.isArray(combo) ? combo : [combo];
|
|
60
89
|
}
|
|
61
90
|
}
|
|
91
|
+
const ui = raw["ui"] as
|
|
92
|
+
| { auto_close?: unknown; editor?: unknown; review_width?: unknown; review_state?: unknown }
|
|
93
|
+
| undefined;
|
|
94
|
+
if (ui && ui.auto_close !== undefined) {
|
|
95
|
+
if (ui.auto_close === "off") out.ui.autoClose = "off";
|
|
96
|
+
else if (typeof ui.auto_close === "number" && ui.auto_close >= 0) out.ui.autoClose = ui.auto_close;
|
|
97
|
+
}
|
|
98
|
+
if (ui && typeof ui.editor === "string" && ui.editor.trim()) out.ui.editor = ui.editor.trim();
|
|
99
|
+
if (ui && typeof ui.review_width === "number" && Number.isFinite(ui.review_width)) {
|
|
100
|
+
out.ui.reviewWidth = clampWidth(ui.review_width);
|
|
101
|
+
}
|
|
102
|
+
if (ui && (ui.review_state === "expanded" || ui.review_state === "compact" || ui.review_state === "hidden")) {
|
|
103
|
+
out.ui.reviewState = ui.review_state;
|
|
104
|
+
}
|
|
62
105
|
const theme = raw["theme"] as Partial<Record<keyof Theme, string>> | undefined;
|
|
63
106
|
if (theme) {
|
|
64
107
|
for (const [token, value] of Object.entries(theme)) {
|
|
@@ -70,29 +113,28 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
|
|
|
70
113
|
const integrations = raw["integrations"] as Record<string, unknown> | undefined;
|
|
71
114
|
const obsidian = integrations?.["obsidian"] as Record<string, unknown> | undefined;
|
|
72
115
|
if (obsidian) {
|
|
73
|
-
const
|
|
74
|
-
if (typeof obsidian["vault"] === "string")
|
|
75
|
-
if (typeof obsidian["folder"] === "string")
|
|
76
|
-
if (typeof obsidian["filenameFormat"] === "string")
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
116
|
+
const obsidianConfig = out.integrations.obsidian;
|
|
117
|
+
if (typeof obsidian["vault"] === "string") obsidianConfig.vault = obsidian["vault"];
|
|
118
|
+
if (typeof obsidian["folder"] === "string") obsidianConfig.folder = obsidian["folder"];
|
|
119
|
+
if (typeof obsidian["filenameFormat"] === "string") obsidianConfig.filenameFormat = obsidian["filenameFormat"];
|
|
120
|
+
const separator = obsidian["separator"];
|
|
121
|
+
if (separator === "space" || separator === "dash" || separator === "underscore") obsidianConfig.separator = separator;
|
|
122
|
+
const exportOn = obsidian["exportOn"];
|
|
123
|
+
if (exportOn === "approve" || exportOn === "resolve" || exportOn === "manual") obsidianConfig.exportOn = exportOn;
|
|
81
124
|
}
|
|
82
125
|
return out;
|
|
83
126
|
}
|
|
84
127
|
|
|
85
|
-
export function loadConfig(
|
|
128
|
+
export function loadConfig(options: { repoRoot?: string; userConfigPath?: string } = {}): CueloopConfig {
|
|
86
129
|
let config: CueloopConfig = {
|
|
87
130
|
keys: { ...DEFAULT_KEYS },
|
|
88
131
|
theme: { ...DARK },
|
|
132
|
+
ui: { autoClose: "off", reviewState: "expanded", reviewWidth: REVIEW_DEFAULT_WIDTH },
|
|
133
|
+
authors: {},
|
|
89
134
|
integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
|
|
90
135
|
};
|
|
91
|
-
const userPath =
|
|
92
|
-
|
|
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]) {
|
|
136
|
+
const userPath = userConfigPathFrom(options.userConfigPath);
|
|
137
|
+
for (const path of [userPath, options.repoRoot ? join(options.repoRoot, ".cueloop", "config.toml") : undefined]) {
|
|
96
138
|
if (!path || !existsSync(path)) continue;
|
|
97
139
|
try {
|
|
98
140
|
config = layer(config, parseToml(readFileSync(path, "utf8")));
|
|
@@ -111,3 +153,73 @@ export function actionFor(keys: Record<string, string[]>, name: string, shift: b
|
|
|
111
153
|
}
|
|
112
154
|
return undefined;
|
|
113
155
|
}
|
|
156
|
+
|
|
157
|
+
function userConfigPathFrom(userConfigPath?: string): string {
|
|
158
|
+
return (
|
|
159
|
+
userConfigPath ??
|
|
160
|
+
process.env.CUELOOP_CONFIG ??
|
|
161
|
+
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml")
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Persist one `[ui]` key into the user config file. Line-level TOML surgery on
|
|
167
|
+
* the keys we own: replace the existing assignment, or drop it under an `[ui]`
|
|
168
|
+
* section (appending the section when absent) - a full TOML writer is not worth
|
|
169
|
+
* its weight here.
|
|
170
|
+
*/
|
|
171
|
+
function persistUiSetting(key: string, rendered: string, userConfigPath?: string): void {
|
|
172
|
+
const path = userConfigPathFrom(userConfigPath);
|
|
173
|
+
let text = "";
|
|
174
|
+
if (existsSync(path)) text = readFileSync(path, "utf8");
|
|
175
|
+
const assignment = new RegExp(`^(\\s*)${key}\\s*=.*$`, "m");
|
|
176
|
+
if (assignment.test(text)) {
|
|
177
|
+
text = text.replace(assignment, `$1${key} = ${rendered}`);
|
|
178
|
+
} else if (/^\[ui\]/m.test(text)) {
|
|
179
|
+
text = text.replace(/^\[ui\]\s*$/m, `[ui]\n${key} = ${rendered}`);
|
|
180
|
+
} else {
|
|
181
|
+
text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[ui]\n${key} = ${rendered}\n`;
|
|
182
|
+
}
|
|
183
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
184
|
+
writeFileSync(path, text);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Persist the auto-close choice (`[ui] auto_close`) into the user config. */
|
|
188
|
+
export function persistAutoClose(value: AutoClose, userConfigPath?: string): void {
|
|
189
|
+
persistUiSetting("auto_close", value === "off" ? '"off"' : String(value), userConfigPath);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Persist the expanded-rail width (`[ui] review_width`) into the user config. */
|
|
193
|
+
export function persistReviewWidth(width: number, userConfigPath?: string): void {
|
|
194
|
+
persistUiSetting("review_width", String(clampWidth(width)), userConfigPath);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Persist the review-panel collapse mode (`[ui] review_state`) into the config. */
|
|
198
|
+
export function persistReviewState(state: ReviewPanelMode, userConfigPath?: string): void {
|
|
199
|
+
persistUiSetting("review_state", `"${state}"`, userConfigPath);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function escapeRegExp(text: string): string {
|
|
203
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Persist an author rename into the `[authors]` table. The identity id (an SSH
|
|
208
|
+
* fingerprint) is not a bare TOML key, so it is written quoted.
|
|
209
|
+
*/
|
|
210
|
+
export function persistAuthorName(id: string, name: string, userConfigPath?: string): void {
|
|
211
|
+
const path = userConfigPathFrom(userConfigPath);
|
|
212
|
+
let text = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
213
|
+
const key = `"${id.replace(/"/g, '\\"')}"`;
|
|
214
|
+
const value = `"${name.replace(/"/g, '\\"')}"`;
|
|
215
|
+
const assignment = new RegExp(`^(\\s*)${escapeRegExp(key)}\\s*=.*$`, "m");
|
|
216
|
+
if (assignment.test(text)) {
|
|
217
|
+
text = text.replace(assignment, `$1${key} = ${value}`);
|
|
218
|
+
} else if (/^\[authors\]/m.test(text)) {
|
|
219
|
+
text = text.replace(/^\[authors\]\s*$/m, `[authors]\n${key} = ${value}`);
|
|
220
|
+
} else {
|
|
221
|
+
text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[authors]\n${key} = ${value}\n`;
|
|
222
|
+
}
|
|
223
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
224
|
+
writeFileSync(path, text);
|
|
225
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
// Assert
|
|
10
|
+
expect(resolveEditor(undefined, {})).toBe("nano");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("the environment chain wins in order: CUELOOP_EDITOR, VISUAL, EDITOR", () => {
|
|
14
|
+
// Assert
|
|
15
|
+
expect(resolveEditor(undefined, { EDITOR: "vim", VISUAL: "code", CUELOOP_EDITOR: "hx" })).toBe("hx");
|
|
16
|
+
expect(resolveEditor(undefined, { EDITOR: "vim", VISUAL: "code" })).toBe("code");
|
|
17
|
+
expect(resolveEditor(undefined, { EDITOR: "vim" })).toBe("vim");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("the [ui] editor config overrides the environment", () => {
|
|
21
|
+
// Assert
|
|
22
|
+
expect(resolveEditor("code --wait", { EDITOR: "vim" })).toBe("code --wait");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("resolveEditorCommand", () => {
|
|
27
|
+
test("a known GUI editor gets its wait flag appended and is trusted to wait", () => {
|
|
28
|
+
// Assert
|
|
29
|
+
expect(resolveEditorCommand("code")).toEqual({ argv: ["code", "--wait"], waits: true });
|
|
30
|
+
expect(resolveEditorCommand("subl")).toEqual({ argv: ["subl", "--new-window", "--wait"], waits: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("an existing wait flag is not duplicated", () => {
|
|
34
|
+
// Assert
|
|
35
|
+
expect(resolveEditorCommand("code --wait").argv).toEqual(["code", "--wait"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a terminal editor is left alone and trusted to hold the tty", () => {
|
|
39
|
+
// Assert
|
|
40
|
+
expect(resolveEditorCommand("vim")).toEqual({ argv: ["vim"], waits: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("an unknown editor is neither flagged nor trusted to wait", () => {
|
|
44
|
+
// Assert
|
|
45
|
+
expect(resolveEditorCommand("my-editor")).toEqual({ argv: ["my-editor"], waits: false });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("editInEditor", () => {
|
|
50
|
+
let dir: string;
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
dir = mkdtempSync(join(tmpdir(), "cueloop-edit-test-"));
|
|
53
|
+
});
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** A runnable editor stub with a chosen base name and body. */
|
|
59
|
+
function stubEditor(name: string, body: string): string {
|
|
60
|
+
const script = join(dir, name);
|
|
61
|
+
writeFileSync(script, `#!/bin/sh\n${body}\n`);
|
|
62
|
+
chmodSync(script, 0o755);
|
|
63
|
+
return script;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const instant = (): (() => number) => {
|
|
67
|
+
let value = 0;
|
|
68
|
+
return () => (value += 5);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
test("an editor that changes the file reports the new content", () => {
|
|
72
|
+
// Act
|
|
73
|
+
const result = editInEditor("old", "plan.md", { editor: stubEditor("writer.sh", `printf 'new' > "$1"`) });
|
|
74
|
+
|
|
75
|
+
// Assert
|
|
76
|
+
expect(result).toEqual({ content: "new", changed: true });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a fast, unchanged return opens the gate; confirming re-reads the saved file", () => {
|
|
80
|
+
// Arrange
|
|
81
|
+
let askedLabel = "";
|
|
82
|
+
|
|
83
|
+
// Act
|
|
84
|
+
const result = editInEditor("keep", "plan.md", {
|
|
85
|
+
editor: stubEditor("gui-like.sh", "exit 0"),
|
|
86
|
+
now: instant(),
|
|
87
|
+
confirmSaved: (label, path) => {
|
|
88
|
+
askedLabel = label;
|
|
89
|
+
writeFileSync(path, "saved in the GUI");
|
|
90
|
+
return true;
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Assert
|
|
95
|
+
expect(askedLabel).toContain("gui-like.sh");
|
|
96
|
+
expect(result).toEqual({ content: "saved in the GUI", changed: true });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("declining the gate keeps the original content", () => {
|
|
100
|
+
// Act
|
|
101
|
+
const result = editInEditor("keep", "plan.md", {
|
|
102
|
+
editor: stubEditor("gui-like.sh", "exit 0"),
|
|
103
|
+
now: instant(),
|
|
104
|
+
confirmSaved: () => false,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Assert
|
|
108
|
+
expect(result).toEqual({ content: "keep", changed: false });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a terminal editor that makes no change never opens the gate", () => {
|
|
112
|
+
// Arrange
|
|
113
|
+
let asked = false;
|
|
114
|
+
|
|
115
|
+
// Act
|
|
116
|
+
const result = editInEditor("keep", "plan.md", {
|
|
117
|
+
editor: stubEditor("vi", "exit 0"),
|
|
118
|
+
now: instant(),
|
|
119
|
+
confirmSaved: () => {
|
|
120
|
+
asked = true;
|
|
121
|
+
return true;
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Assert
|
|
126
|
+
expect(asked).toBeFalse();
|
|
127
|
+
expect(result).toEqual({ content: "keep", changed: false });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a nonzero editor exit throws", () => {
|
|
131
|
+
// Assert
|
|
132
|
+
expect(() => editInEditor("x", "plan.md", { editor: stubEditor("fail.sh", "exit 3") })).toThrow("editor exited 3");
|
|
133
|
+
});
|
|
134
|
+
});
|
package/src/editor.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* File-level edit mode = the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 });
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { focusHerdrPane } from "./herdr";
|
|
6
|
+
|
|
7
|
+
let dir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-"));
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
rmSync(dir, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("focusHerdrPane", () => {
|
|
17
|
+
test("resolves the pane's tab and focuses it", () => {
|
|
18
|
+
// Arrange
|
|
19
|
+
const log = join(dir, "calls.log");
|
|
20
|
+
const stub = join(dir, "herdr");
|
|
21
|
+
writeFileSync(
|
|
22
|
+
stub,
|
|
23
|
+
`#!/bin/sh
|
|
24
|
+
echo "$@" >> "${log}"
|
|
25
|
+
if [ "$1" = "pane" ]; then echo '{"result":{"pane":{"tab_id":"w1:t3"}}}'; fi
|
|
26
|
+
`,
|
|
27
|
+
);
|
|
28
|
+
Bun.spawnSync(["chmod", "+x", stub]);
|
|
29
|
+
|
|
30
|
+
// Assert
|
|
31
|
+
expect(focusHerdrPane(stub, "w1:p1")).toBe(true);
|
|
32
|
+
const calls = readFileSync(log, "utf8").trim().split("\n");
|
|
33
|
+
expect(calls[0]).toBe("pane get w1:p1");
|
|
34
|
+
expect(calls[1]).toBe("tab focus w1:t3");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("a missing binary never throws", () => {
|
|
38
|
+
// Assert
|
|
39
|
+
expect(focusHerdrPane(join(dir, "nope"), "w1:p1")).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
package/src/herdr.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-class herdr return: a review opened beside an agent hands focus back
|
|
3
|
+
* to the agent's pane when it closes, not leaving the reviewer on a dead tab.
|
|
4
|
+
* The env contract and the return-target rule live in @cueloop/schema; this
|
|
5
|
+
* module owns the one reviewer-side action that spawns the herdr CLI. Focus
|
|
6
|
+
* is best-effort - a failure never blocks closing the review.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Focus the tab holding the target pane. Synchronous: runs just before exit. */
|
|
10
|
+
export function focusHerdrPane(binPath: string, paneId: string): boolean {
|
|
11
|
+
try {
|
|
12
|
+
const got = Bun.spawnSync([binPath, "pane", "get", paneId], { stdout: "pipe", stderr: "ignore" });
|
|
13
|
+
if (got.exitCode !== 0) return false;
|
|
14
|
+
const parsed = JSON.parse(got.stdout.toString()) as { result?: { pane?: { tab_id?: string } } };
|
|
15
|
+
const tab = parsed.result?.pane?.tab_id;
|
|
16
|
+
if (!tab) return false;
|
|
17
|
+
return Bun.spawnSync([binPath, "tab", "focus", tab], { stdout: "ignore", stderr: "ignore" }).exitCode === 0;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
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 { collaboratorAnnotations, publishShare, pullShare, pushShare, shareIdFromLine, type ShareResult, type ShareTarget } from "./share";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { OBSIDIAN_DEFAULTS } from "@cueloop/integration-obsidian";
|
|
3
|
+
import { loadBundledExporters } from "./integrations";
|
|
4
|
+
import type { IntegrationsConfig } from "./config";
|
|
5
|
+
|
|
6
|
+
function integrations(overrides: Partial<typeof OBSIDIAN_DEFAULTS> = {}): IntegrationsConfig {
|
|
7
|
+
return { obsidian: { ...OBSIDIAN_DEFAULTS, ...overrides } };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("loadBundledExporters", () => {
|
|
11
|
+
test("composes the obsidian integration into one named exporter", async () => {
|
|
12
|
+
// Act
|
|
13
|
+
const exporters = await loadBundledExporters(integrations());
|
|
14
|
+
|
|
15
|
+
// Assert
|
|
16
|
+
expect(exporters.map((exporter) => exporter.name)).toEqual(["obsidian"]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("runsOn mirrors the configured export policy", async () => {
|
|
20
|
+
// Arrange
|
|
21
|
+
const onApprove = (await loadBundledExporters(integrations({ exportOn: "approve" })))[0]!;
|
|
22
|
+
|
|
23
|
+
// Assert
|
|
24
|
+
expect(onApprove.runsOn("approve")).toBeTrue();
|
|
25
|
+
expect(onApprove.runsOn("request_changes")).toBeFalse();
|
|
26
|
+
|
|
27
|
+
// Arrange
|
|
28
|
+
const onResolve = (await loadBundledExporters(integrations({ exportOn: "resolve" })))[0]!;
|
|
29
|
+
|
|
30
|
+
// Assert
|
|
31
|
+
expect(onResolve.runsOn("approve")).toBeTrue();
|
|
32
|
+
expect(onResolve.runsOn("request_changes")).toBeTrue();
|
|
33
|
+
|
|
34
|
+
// Arrange
|
|
35
|
+
const manual = (await loadBundledExporters(integrations({ exportOn: "manual" })))[0]!;
|
|
36
|
+
|
|
37
|
+
// Assert
|
|
38
|
+
expect(manual.runsOn("approve")).toBeFalse();
|
|
39
|
+
expect(manual.runsOn("request_changes")).toBeFalse();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundled integration wiring: composes the configured integrations into
|
|
3
|
+
* generic exporters plus their run policy, so the session controller depends
|
|
4
|
+
* only on the extension seam, never on an integration's own config type. This
|
|
5
|
+
* and config.ts are the only client modules that name a concrete integration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Registry, type Exporter } from "@cueloop/extension-api";
|
|
9
|
+
import { createObsidianExtension, shouldExport } from "@cueloop/integration-obsidian";
|
|
10
|
+
import type { VerdictKind } from "@cueloop/schema";
|
|
11
|
+
import type { IntegrationsConfig } from "./config";
|
|
12
|
+
|
|
13
|
+
/** An exporter plus the policy deciding which verdicts trigger it. */
|
|
14
|
+
export interface BundledExporter {
|
|
15
|
+
name: string;
|
|
16
|
+
run: Exporter;
|
|
17
|
+
runsOn(verdict: VerdictKind): boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Load the bundled integrations from config as generic exporters. */
|
|
21
|
+
export async function loadBundledExporters(integrations: IntegrationsConfig): Promise<BundledExporter[]> {
|
|
22
|
+
const registry = new Registry();
|
|
23
|
+
const obsidian = integrations.obsidian;
|
|
24
|
+
const record = await registry.load("obsidian", createObsidianExtension(obsidian));
|
|
25
|
+
const run = record.exporters.get("obsidian");
|
|
26
|
+
if (!run) return [];
|
|
27
|
+
return [{ name: "obsidian", run, runsOn: (verdict) => shouldExport(obsidian.exportOn, verdict) }];
|
|
28
|
+
}
|