@cueloop/client 0.1.0-alpha.17 → 0.1.0-alpha.19
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 +5 -5
- package/src/App.tsx +1 -1
- package/src/herdr.test.ts +3 -34
- package/src/herdr.ts +8 -21
- package/src/integrations.test.ts +29 -0
- package/src/integrations.ts +28 -0
- package/src/session-controller.ts +13 -16
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.19",
|
|
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.
|
|
11
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
12
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
13
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
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",
|
|
14
14
|
"@opentui/core": "^0.5.1",
|
|
15
15
|
"@opentui/keymap": "0.5.1",
|
|
16
16
|
"@opentui/react": "^0.5.1",
|
package/src/App.tsx
CHANGED
|
@@ -17,7 +17,7 @@ import { displayText, marksByDisplay, spanKey, startSpan, type Mark, type SpanSt
|
|
|
17
17
|
import { noteForFile, viewedCount } from "./walk";
|
|
18
18
|
import { DARK, dimmedTheme } from "./theme";
|
|
19
19
|
import { DEFAULT_KEYS, loadConfig } from "./config";
|
|
20
|
-
import { returnPaneFor } from "
|
|
20
|
+
import { returnPaneFor } from "@cueloop/schema";
|
|
21
21
|
import { createReviewController } from "./session-controller";
|
|
22
22
|
import { reduceKey, type Intent, type KeyState } from "./keymap";
|
|
23
23
|
import { KeyBindings, type HintMode } from "./key-bindings";
|
package/src/herdr.test.ts
CHANGED
|
@@ -2,46 +2,17 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { focusHerdrPane
|
|
5
|
+
import { focusHerdrPane } from "./herdr";
|
|
6
6
|
|
|
7
7
|
let dir: string;
|
|
8
|
-
const savedEnv: Record<string, string | undefined> = {};
|
|
9
8
|
|
|
10
9
|
beforeEach(() => {
|
|
11
10
|
dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-"));
|
|
12
|
-
for (const k of ["HERDR_ENV", "HERDR_PANE_ID", "HERDR_BIN_PATH", "CUELOOP_RETURN_PANE"]) {
|
|
13
|
-
savedEnv[k] = process.env[k];
|
|
14
|
-
delete process.env[k];
|
|
15
|
-
}
|
|
16
11
|
});
|
|
17
12
|
afterEach(() => {
|
|
18
|
-
for (const [k, v] of Object.entries(savedEnv)) {
|
|
19
|
-
if (v === undefined) delete process.env[k];
|
|
20
|
-
else process.env[k] = v;
|
|
21
|
-
}
|
|
22
13
|
rmSync(dir, { recursive: true, force: true });
|
|
23
14
|
});
|
|
24
15
|
|
|
25
|
-
describe("returnPaneFor", () => {
|
|
26
|
-
test("outside herdr there is never a return target", () => {
|
|
27
|
-
expect(returnPaneFor("w1:p1")).toBeUndefined();
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test("env override wins, session meta is the fallback", () => {
|
|
31
|
-
process.env.HERDR_ENV = "1";
|
|
32
|
-
process.env.HERDR_PANE_ID = "w1:p9";
|
|
33
|
-
expect(returnPaneFor("w1:p1")).toBe("w1:p1");
|
|
34
|
-
process.env.CUELOOP_RETURN_PANE = "w1:p2";
|
|
35
|
-
expect(returnPaneFor("w1:p1")).toBe("w1:p2");
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("returning to our own pane is a no-op", () => {
|
|
39
|
-
process.env.HERDR_ENV = "1";
|
|
40
|
-
process.env.HERDR_PANE_ID = "w1:p1";
|
|
41
|
-
expect(returnPaneFor("w1:p1")).toBeUndefined();
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
16
|
describe("focusHerdrPane", () => {
|
|
46
17
|
test("resolves the pane's tab and focuses it", () => {
|
|
47
18
|
const log = join(dir, "calls.log");
|
|
@@ -54,15 +25,13 @@ if [ "$1" = "pane" ]; then echo '{"result":{"pane":{"tab_id":"w1:t3"}}}'; fi
|
|
|
54
25
|
`,
|
|
55
26
|
);
|
|
56
27
|
Bun.spawnSync(["chmod", "+x", stub]);
|
|
57
|
-
|
|
58
|
-
expect(focusHerdrPane("w1:p1")).toBe(true);
|
|
28
|
+
expect(focusHerdrPane(stub, "w1:p1")).toBe(true);
|
|
59
29
|
const calls = readFileSync(log, "utf8").trim().split("\n");
|
|
60
30
|
expect(calls[0]).toBe("pane get w1:p1");
|
|
61
31
|
expect(calls[1]).toBe("tab focus w1:t3");
|
|
62
32
|
});
|
|
63
33
|
|
|
64
34
|
test("a missing binary never throws", () => {
|
|
65
|
-
|
|
66
|
-
expect(focusHerdrPane("w1:p1")).toBe(false);
|
|
35
|
+
expect(focusHerdrPane(join(dir, "nope"), "w1:p1")).toBe(false);
|
|
67
36
|
});
|
|
68
37
|
});
|
package/src/herdr.ts
CHANGED
|
@@ -1,33 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* First-class herdr return: a review opened beside an agent
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* best-effort: focus failures never block closing the review.
|
|
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.
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
|
-
export function insideHerdr(): boolean {
|
|
11
|
-
return process.env.HERDR_ENV === "1";
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function returnPaneFor(sessionHerdrPane?: string): string | undefined {
|
|
15
|
-
if (!insideHerdr()) return undefined;
|
|
16
|
-
const pane = process.env.CUELOOP_RETURN_PANE ?? sessionHerdrPane;
|
|
17
|
-
// returning to our own pane would be a no-op at best
|
|
18
|
-
return pane && pane !== process.env.HERDR_PANE_ID ? pane : undefined;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
9
|
/** Focus the tab holding the target pane. Synchronous: runs just before exit. */
|
|
22
|
-
export function focusHerdrPane(paneId: string): boolean {
|
|
23
|
-
const bin = process.env.HERDR_BIN_PATH ?? "herdr";
|
|
10
|
+
export function focusHerdrPane(binPath: string, paneId: string): boolean {
|
|
24
11
|
try {
|
|
25
|
-
const got = Bun.spawnSync([
|
|
12
|
+
const got = Bun.spawnSync([binPath, "pane", "get", paneId], { stdout: "pipe", stderr: "ignore" });
|
|
26
13
|
if (got.exitCode !== 0) return false;
|
|
27
14
|
const parsed = JSON.parse(got.stdout.toString()) as { result?: { pane?: { tab_id?: string } } };
|
|
28
15
|
const tab = parsed.result?.pane?.tab_id;
|
|
29
16
|
if (!tab) return false;
|
|
30
|
-
return Bun.spawnSync([
|
|
17
|
+
return Bun.spawnSync([binPath, "tab", "focus", tab], { stdout: "ignore", stderr: "ignore" }).exitCode === 0;
|
|
31
18
|
} catch {
|
|
32
19
|
return false;
|
|
33
20
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
const exporters = await loadBundledExporters(integrations());
|
|
13
|
+
expect(exporters.map((exporter) => exporter.name)).toEqual(["obsidian"]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("runsOn mirrors the configured export policy", async () => {
|
|
17
|
+
const onApprove = (await loadBundledExporters(integrations({ exportOn: "approve" })))[0]!;
|
|
18
|
+
expect(onApprove.runsOn("approve")).toBeTrue();
|
|
19
|
+
expect(onApprove.runsOn("request_changes")).toBeFalse();
|
|
20
|
+
|
|
21
|
+
const onResolve = (await loadBundledExporters(integrations({ exportOn: "resolve" })))[0]!;
|
|
22
|
+
expect(onResolve.runsOn("approve")).toBeTrue();
|
|
23
|
+
expect(onResolve.runsOn("request_changes")).toBeTrue();
|
|
24
|
+
|
|
25
|
+
const manual = (await loadBundledExporters(integrations({ exportOn: "manual" })))[0]!;
|
|
26
|
+
expect(manual.runsOn("approve")).toBeFalse();
|
|
27
|
+
expect(manual.runsOn("request_changes")).toBeFalse();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -11,22 +11,23 @@ import { SystemClock, type Clock, type TimerHandle } from "@opentui/core";
|
|
|
11
11
|
import { DaemonClient } from "@cueloop/daemon/client";
|
|
12
12
|
import {
|
|
13
13
|
cutBlock,
|
|
14
|
+
detectHerdr,
|
|
14
15
|
makeAnchor,
|
|
15
16
|
newAnnotationId,
|
|
16
17
|
parseBlocks,
|
|
17
18
|
resolveAnchor,
|
|
18
19
|
restoreBlock,
|
|
19
20
|
restoreLine,
|
|
21
|
+
returnPaneFor,
|
|
20
22
|
type ReviewSession,
|
|
21
23
|
type VerdictKind,
|
|
22
24
|
} from "@cueloop/schema";
|
|
23
|
-
import {
|
|
24
|
-
import { createObsidianExtension, shouldExport, type ObsidianConfig } from "@cueloop/integration-obsidian";
|
|
25
|
+
import { loadBundledExporters, type BundledExporter } from "./integrations";
|
|
25
26
|
import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
|
|
26
27
|
import { diffRowAnchor, diffRows, type DiffRow } from "./view-diff";
|
|
27
28
|
import { firstUnviewedIndex, walkFiles, type WalkFile } from "./walk";
|
|
28
29
|
import { editInEditor } from "./editor";
|
|
29
|
-
import { focusHerdrPane
|
|
30
|
+
import { focusHerdrPane } from "./herdr";
|
|
30
31
|
import { persistAutoClose, type AutoClose, type CueloopConfig } from "./config";
|
|
31
32
|
|
|
32
33
|
/**
|
|
@@ -139,8 +140,7 @@ class Controller implements ReviewController {
|
|
|
139
140
|
};
|
|
140
141
|
private listeners = new Set<() => void>();
|
|
141
142
|
private autoClose: AutoClose = "off";
|
|
142
|
-
private
|
|
143
|
-
private exporters = new Map<string, Exporter>();
|
|
143
|
+
private exporters: BundledExporter[] = [];
|
|
144
144
|
private readonly clock: Clock;
|
|
145
145
|
private countdown: TimerHandle | undefined;
|
|
146
146
|
/** Projections keyed by session identity so renders reuse one computation. */
|
|
@@ -205,11 +205,8 @@ class Controller implements ReviewController {
|
|
|
205
205
|
|
|
206
206
|
applyConfig(config: CueloopConfig): void {
|
|
207
207
|
this.autoClose = config.ui.autoClose;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const registry = new Registry();
|
|
211
|
-
void registry.load("obsidian", createObsidianExtension(config.integrations.obsidian)).then((record) => {
|
|
212
|
-
this.exporters = record.exporters;
|
|
208
|
+
void loadBundledExporters(config.integrations).then((exporters) => {
|
|
209
|
+
this.exporters = exporters;
|
|
213
210
|
});
|
|
214
211
|
}
|
|
215
212
|
|
|
@@ -446,11 +443,10 @@ class Controller implements ReviewController {
|
|
|
446
443
|
// The completion overlay heading already states the verdict, so the
|
|
447
444
|
// status line stays empty here - only export/error messages fill it.
|
|
448
445
|
this.update({ session: resolved, status: "" });
|
|
449
|
-
// notes-vault export: guarded by
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
void exporter(resolved).then((exportResult) => {
|
|
446
|
+
// notes-vault export: guarded by each exporter's policy (default manual = no-op)
|
|
447
|
+
for (const exporter of this.exporters) {
|
|
448
|
+
if (!exporter.runsOn(verdict)) continue;
|
|
449
|
+
void exporter.run(resolved).then((exportResult) => {
|
|
454
450
|
this.setStatus(exportResult.success && exportResult.path ? `exported to ${exportResult.path}` : `export failed: ${exportResult.error ?? "unknown"}`);
|
|
455
451
|
});
|
|
456
452
|
}
|
|
@@ -474,8 +470,9 @@ class Controller implements ReviewController {
|
|
|
474
470
|
|
|
475
471
|
finishReview(): void {
|
|
476
472
|
this.clearCountdown();
|
|
473
|
+
const herdr = detectHerdr();
|
|
477
474
|
const pane = returnPaneFor(this.snapshot.session?.artifact.meta.herdrPane);
|
|
478
|
-
if (pane) focusHerdrPane(pane);
|
|
475
|
+
if (herdr && pane) focusHerdrPane(herdr.binPath, pane);
|
|
479
476
|
this.options.onExit?.(0);
|
|
480
477
|
}
|
|
481
478
|
|