@cueloop/client 0.1.0-alpha.5 → 0.1.0-alpha.7
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 +7 -6
- package/src/App.autoclose.test.tsx +118 -0
- package/src/App.rendering.test.tsx +108 -0
- package/src/App.test.tsx +2 -1
- package/src/App.tsx +0 -0
- package/src/config.ts +37 -0
- package/src/herdr.test.ts +68 -0
- package/src/herdr.ts +34 -0
- package/src/syntax.test.ts +41 -0
- package/src/syntax.ts +88 -0
- package/src/view.test.ts +6 -6
- package/src/view.ts +0 -0
package/package.json
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.7",
|
|
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.7",
|
|
11
|
+
"@cueloop/extension-api": "0.1.0-alpha.7",
|
|
12
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.7",
|
|
13
|
+
"@cueloop/schema": "0.1.0-alpha.7",
|
|
14
14
|
"@opentui/core": "^0.4.3",
|
|
15
15
|
"@opentui/react": "^0.4.3",
|
|
16
|
+
"@opentui/ssh": "0.4.5",
|
|
16
17
|
"@pierre/diffs": "^1.3.5",
|
|
17
18
|
"react": "^19.2.0",
|
|
18
|
-
"
|
|
19
|
+
"shiki": "^4.4.2"
|
|
19
20
|
},
|
|
20
21
|
"devDependencies": {
|
|
21
22
|
"@types/react": "^19.2.0",
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** Post-submit completion overlay: prompt, opt-in persistence, countdown, dismissal. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
9
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
10
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
11
|
+
import { App } from "./App";
|
|
12
|
+
|
|
13
|
+
const PLAN = "# Plan\n\nShip the thing.\n";
|
|
14
|
+
|
|
15
|
+
let home: string;
|
|
16
|
+
let server: DaemonServer;
|
|
17
|
+
let session: ReviewSession;
|
|
18
|
+
let configPath: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-close-"));
|
|
22
|
+
configPath = join(home, "config.toml");
|
|
23
|
+
process.env.CUELOOP_CONFIG = configPath;
|
|
24
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
25
|
+
server.start();
|
|
26
|
+
session = server.core.sessionCreate({
|
|
27
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
28
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
delete process.env.CUELOOP_CONFIG;
|
|
33
|
+
server.stop();
|
|
34
|
+
rmSync(home, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
async function renderApp(onExit?: (code: number) => void) {
|
|
38
|
+
const setup = await testRender(<App home={home} sessionId={session.id} onExit={onExit} />, {
|
|
39
|
+
width: 120,
|
|
40
|
+
height: 30,
|
|
41
|
+
});
|
|
42
|
+
for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
|
|
43
|
+
await Bun.sleep(25);
|
|
44
|
+
await setup.renderOnce();
|
|
45
|
+
}
|
|
46
|
+
return setup;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
50
|
+
async function press(setup: Setup, k: string): Promise<void> {
|
|
51
|
+
if (k === "enter") setup.mockInput.pressKey("RETURN");
|
|
52
|
+
else if (k === "escape") setup.mockInput.pressKey("ESCAPE");
|
|
53
|
+
else await setup.mockInput.typeText(k);
|
|
54
|
+
await Bun.sleep(15);
|
|
55
|
+
await setup.renderOnce();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function submitApprove(setup: Setup): Promise<void> {
|
|
59
|
+
await press(setup, "enter"); // open submit (approve default: no pending items)
|
|
60
|
+
await press(setup, "enter"); // confirm
|
|
61
|
+
const deadline = Date.now() + 10_000;
|
|
62
|
+
while (Date.now() < deadline && !setup.captureCharFrame().includes("review approved")) {
|
|
63
|
+
await Bun.sleep(25);
|
|
64
|
+
await setup.renderOnce();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("completion overlay", () => {
|
|
69
|
+
test("submit with auto-close off shows the prompt; ⏎ exits", async () => {
|
|
70
|
+
let exited = -1;
|
|
71
|
+
const setup = await renderApp((code) => (exited = code));
|
|
72
|
+
await submitApprove(setup);
|
|
73
|
+
const frame = setup.captureCharFrame();
|
|
74
|
+
expect(frame).toContain("✓ review approved");
|
|
75
|
+
expect(frame).toContain("a always close after submit");
|
|
76
|
+
await press(setup, "enter");
|
|
77
|
+
expect(exited).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a opts in: persists the config and starts the countdown", async () => {
|
|
81
|
+
const setup = await renderApp();
|
|
82
|
+
await submitApprove(setup);
|
|
83
|
+
await press(setup, "a");
|
|
84
|
+
expect(setup.captureCharFrame()).toContain("closing in 3s");
|
|
85
|
+
expect(existsSync(configPath)).toBe(true);
|
|
86
|
+
expect(readFileSync(configPath, "utf8")).toContain("auto_close = 3");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("esc dismisses to the resolved read-only view", async () => {
|
|
90
|
+
const setup = await renderApp();
|
|
91
|
+
await submitApprove(setup);
|
|
92
|
+
await press(setup, "escape");
|
|
93
|
+
// a bare ESC sits in the input parser's escape-sequence disambiguation
|
|
94
|
+
// window before it is delivered - poll for the dismissal
|
|
95
|
+
const deadline = Date.now() + 5_000;
|
|
96
|
+
while (Date.now() < deadline && !setup.captureCharFrame().includes("resolved: approve")) {
|
|
97
|
+
await Bun.sleep(50);
|
|
98
|
+
await setup.renderOnce();
|
|
99
|
+
}
|
|
100
|
+
const frame = setup.captureCharFrame();
|
|
101
|
+
expect(frame).toContain("resolved: approve");
|
|
102
|
+
expect(frame).not.toContain("always close after submit");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("configured auto_close counts down and exits without interaction", async () => {
|
|
106
|
+
writeFileSync(configPath, "[ui]\nauto_close = 1\n");
|
|
107
|
+
let exited = -1;
|
|
108
|
+
const setup = await renderApp((code) => (exited = code));
|
|
109
|
+
await submitApprove(setup);
|
|
110
|
+
expect(setup.captureCharFrame()).toContain("closing in 1s");
|
|
111
|
+
const deadline = Date.now() + 10_000;
|
|
112
|
+
while (Date.now() < deadline && exited === -1) {
|
|
113
|
+
await Bun.sleep(100);
|
|
114
|
+
await setup.renderOnce();
|
|
115
|
+
}
|
|
116
|
+
expect(exited).toBe(0);
|
|
117
|
+
}, 20_000);
|
|
118
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** Plan rendering (#67): code blocks verbatim + contained, spacing never collapses. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
9
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
10
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
11
|
+
import { App } from "./App";
|
|
12
|
+
|
|
13
|
+
const PLAN = `# Render Plan
|
|
14
|
+
|
|
15
|
+
## Sensor
|
|
16
|
+
|
|
17
|
+
- wire the duck over MQTT
|
|
18
|
+
|
|
19
|
+
\`\`\`tsx
|
|
20
|
+
export function Gate({ full }: { full: number }) {
|
|
21
|
+
const blocked = full < 0.6;
|
|
22
|
+
return <text>{blocked ? "blocked" : "clear"}</text>;
|
|
23
|
+
}
|
|
24
|
+
\`\`\`
|
|
25
|
+
|
|
26
|
+
## Gate
|
|
27
|
+
|
|
28
|
+
Deploys block below the threshold.
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
let home: string;
|
|
32
|
+
let server: DaemonServer;
|
|
33
|
+
let session: ReviewSession;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-render-"));
|
|
37
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
38
|
+
server.start();
|
|
39
|
+
session = server.core.sessionCreate({
|
|
40
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
41
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Render Plan" } },
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
server.stop();
|
|
46
|
+
rmSync(home, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
async function frame(): Promise<string> {
|
|
50
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 36 });
|
|
51
|
+
const deadline = Date.now() + 10_000;
|
|
52
|
+
while (Date.now() < deadline && !setup.captureCharFrame().includes("Render Plan")) {
|
|
53
|
+
await Bun.sleep(25);
|
|
54
|
+
await setup.renderOnce();
|
|
55
|
+
}
|
|
56
|
+
// give the async highlighter a beat, then take the final frame
|
|
57
|
+
await Bun.sleep(150);
|
|
58
|
+
await setup.renderOnce();
|
|
59
|
+
return setup.captureCharFrame();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("code blocks", () => {
|
|
63
|
+
test("lines render verbatim: indentation preserved, no word-wrap", async () => {
|
|
64
|
+
const f = await frame();
|
|
65
|
+
expect(f).toContain(" const blocked = full < 0.6;");
|
|
66
|
+
expect(f).toContain(' return <text>{blocked ? "blocked" : "clear"}</text>;');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("the block carries its language tag", async () => {
|
|
70
|
+
const f = await frame();
|
|
71
|
+
expect(f).toContain("tsx");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("block spacing", () => {
|
|
76
|
+
test("a code block never glues to the list item above it", async () => {
|
|
77
|
+
const lines = (await frame()).split("\n").map((l) => l.trimEnd());
|
|
78
|
+
const li = lines.findIndex((l) => l.includes("wire the duck over MQTT"));
|
|
79
|
+
const codeHeader = lines.findIndex((l) => l.trim().startsWith("tsx"));
|
|
80
|
+
expect(li).toBeGreaterThan(-1);
|
|
81
|
+
expect(codeHeader).toBeGreaterThan(li);
|
|
82
|
+
expect(lines.slice(li + 1, codeHeader).some((l) => l === "")).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a heading never sits directly on the previous block", async () => {
|
|
86
|
+
const lines = (await frame()).split("\n").map((l) => l.trimEnd());
|
|
87
|
+
const gate = lines.findIndex((l) => l.trim() === "Gate");
|
|
88
|
+
expect(gate).toBeGreaterThan(0);
|
|
89
|
+
expect(lines[gate - 1]).toBe("");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("consecutive list items stay tight", async () => {
|
|
93
|
+
const listPlan = "# T\n\n- one\n- two\n";
|
|
94
|
+
const s2 = server.core.sessionCreate({
|
|
95
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
96
|
+
artifact: { type: "plan", content: listPlan, meta: {} },
|
|
97
|
+
});
|
|
98
|
+
const setup = await testRender(<App home={home} sessionId={s2.id} />, { width: 120, height: 30 });
|
|
99
|
+
const deadline = Date.now() + 10_000;
|
|
100
|
+
while (Date.now() < deadline && !setup.captureCharFrame().includes("one")) {
|
|
101
|
+
await Bun.sleep(25);
|
|
102
|
+
await setup.renderOnce();
|
|
103
|
+
}
|
|
104
|
+
const lines = setup.captureCharFrame().split("\n").map((l) => l.trimEnd());
|
|
105
|
+
const one = lines.findIndex((l) => l.includes("- one"));
|
|
106
|
+
expect(lines[one + 1]).toContain("- two");
|
|
107
|
+
});
|
|
108
|
+
});
|
package/src/App.test.tsx
CHANGED
|
@@ -186,7 +186,8 @@ describe("submit", () => {
|
|
|
186
186
|
expect(stored.status).toBe("resolved");
|
|
187
187
|
expect(stored.verdict!.kind).toBe("request_changes");
|
|
188
188
|
expect(stored.verdict!.feedback).toContain("Needs a phase list.");
|
|
189
|
-
|
|
189
|
+
// submit hands the reviewer back to the agent via the completion overlay
|
|
190
|
+
expect(setup.captureCharFrame()).toContain("✎ feedback sent");
|
|
190
191
|
});
|
|
191
192
|
|
|
192
193
|
test("approve via ←/→ verdict cycling", async () => {
|
package/src/App.tsx
CHANGED
|
Binary file
|
package/src/config.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { OBSIDIAN_DEFAULTS, type ObsidianConfig } from "@cueloop/integration-obsidian";
|
|
11
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
12
|
+
import { dirname } from "node:path";
|
|
11
13
|
import { DARK, type Theme } from "./theme";
|
|
12
14
|
|
|
13
15
|
export interface KeymapConfig {
|
|
@@ -18,9 +20,13 @@ export interface IntegrationsConfig {
|
|
|
18
20
|
obsidian: ObsidianConfig;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
/** Post-submit behavior: "off" prompts, 0 closes instantly, N counts down. */
|
|
24
|
+
export type AutoClose = "off" | number;
|
|
25
|
+
|
|
21
26
|
export interface CueloopConfig {
|
|
22
27
|
keys: Record<string, string[]>;
|
|
23
28
|
theme: Theme;
|
|
29
|
+
ui: { autoClose: AutoClose };
|
|
24
30
|
integrations: IntegrationsConfig;
|
|
25
31
|
}
|
|
26
32
|
|
|
@@ -51,6 +57,7 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
|
|
|
51
57
|
const out: CueloopConfig = {
|
|
52
58
|
keys: { ...base.keys },
|
|
53
59
|
theme: { ...base.theme },
|
|
60
|
+
ui: { ...base.ui },
|
|
54
61
|
integrations: { obsidian: { ...base.integrations.obsidian } },
|
|
55
62
|
};
|
|
56
63
|
const keys = raw["keys"] as KeymapConfig | undefined;
|
|
@@ -59,6 +66,11 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
|
|
|
59
66
|
out.keys[action] = Array.isArray(combo) ? combo : [combo];
|
|
60
67
|
}
|
|
61
68
|
}
|
|
69
|
+
const ui = raw["ui"] as { auto_close?: unknown } | undefined;
|
|
70
|
+
if (ui && ui.auto_close !== undefined) {
|
|
71
|
+
if (ui.auto_close === "off") out.ui.autoClose = "off";
|
|
72
|
+
else if (typeof ui.auto_close === "number" && ui.auto_close >= 0) out.ui.autoClose = ui.auto_close;
|
|
73
|
+
}
|
|
62
74
|
const theme = raw["theme"] as Partial<Record<keyof Theme, string>> | undefined;
|
|
63
75
|
if (theme) {
|
|
64
76
|
for (const [token, value] of Object.entries(theme)) {
|
|
@@ -86,6 +98,7 @@ export function loadConfig(opts: { repoRoot?: string; userConfigPath?: string }
|
|
|
86
98
|
let config: CueloopConfig = {
|
|
87
99
|
keys: { ...DEFAULT_KEYS },
|
|
88
100
|
theme: { ...DARK },
|
|
101
|
+
ui: { autoClose: "off" },
|
|
89
102
|
integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
|
|
90
103
|
};
|
|
91
104
|
const userPath =
|
|
@@ -111,3 +124,27 @@ export function actionFor(keys: Record<string, string[]>, name: string, shift: b
|
|
|
111
124
|
}
|
|
112
125
|
return undefined;
|
|
113
126
|
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Persist the auto-close choice into the user config file. Line-level TOML
|
|
130
|
+
* surgery on the one key we own: replace an existing `auto_close`, or append
|
|
131
|
+
* an `[ui]` section - a full TOML writer is not worth its weight here.
|
|
132
|
+
*/
|
|
133
|
+
export function persistAutoClose(value: AutoClose, userConfigPath?: string): void {
|
|
134
|
+
const path =
|
|
135
|
+
userConfigPath ??
|
|
136
|
+
process.env.CUELOOP_CONFIG ??
|
|
137
|
+
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml");
|
|
138
|
+
const rendered = value === "off" ? '"off"' : String(value);
|
|
139
|
+
let text = "";
|
|
140
|
+
if (existsSync(path)) text = readFileSync(path, "utf8");
|
|
141
|
+
if (/^\s*auto_close\s*=/m.test(text)) {
|
|
142
|
+
text = text.replace(/^(\s*)auto_close\s*=.*$/m, `$1auto_close = ${rendered}`);
|
|
143
|
+
} else if (/^\[ui\]/m.test(text)) {
|
|
144
|
+
text = text.replace(/^\[ui\]\s*$/m, `[ui]\nauto_close = ${rendered}`);
|
|
145
|
+
} else {
|
|
146
|
+
text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[ui]\nauto_close = ${rendered}\n`;
|
|
147
|
+
}
|
|
148
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
149
|
+
writeFileSync(path, text);
|
|
150
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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, returnPaneFor } from "./herdr";
|
|
6
|
+
|
|
7
|
+
let dir: string;
|
|
8
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
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
|
+
});
|
|
17
|
+
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
|
+
rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
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
|
+
describe("focusHerdrPane", () => {
|
|
46
|
+
test("resolves the pane's tab and focuses it", () => {
|
|
47
|
+
const log = join(dir, "calls.log");
|
|
48
|
+
const stub = join(dir, "herdr");
|
|
49
|
+
writeFileSync(
|
|
50
|
+
stub,
|
|
51
|
+
`#!/bin/sh
|
|
52
|
+
echo "$@" >> "${log}"
|
|
53
|
+
if [ "$1" = "pane" ]; then echo '{"result":{"pane":{"tab_id":"w1:t3"}}}'; fi
|
|
54
|
+
`,
|
|
55
|
+
);
|
|
56
|
+
Bun.spawnSync(["chmod", "+x", stub]);
|
|
57
|
+
process.env.HERDR_BIN_PATH = stub;
|
|
58
|
+
expect(focusHerdrPane("w1:p1")).toBe(true);
|
|
59
|
+
const calls = readFileSync(log, "utf8").trim().split("\n");
|
|
60
|
+
expect(calls[0]).toBe("pane get w1:p1");
|
|
61
|
+
expect(calls[1]).toBe("tab focus w1:t3");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a missing binary never throws", () => {
|
|
65
|
+
process.env.HERDR_BIN_PATH = join(dir, "nope");
|
|
66
|
+
expect(focusHerdrPane("w1:p1")).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
package/src/herdr.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-class herdr return (#23): a review opened beside an agent should hand
|
|
3
|
+
* focus back to the agent's pane when it closes, not leave the reviewer on a
|
|
4
|
+
* dead tab. Detection is the ambient env contract; the return target comes
|
|
5
|
+
* from CUELOOP_RETURN_PANE (set by whoever opened this pane) or the session's
|
|
6
|
+
* recorded herdrPane (set by the adapter hook). Everything here is
|
|
7
|
+
* best-effort: focus failures never block closing the review.
|
|
8
|
+
*/
|
|
9
|
+
|
|
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
|
+
/** 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";
|
|
24
|
+
try {
|
|
25
|
+
const got = Bun.spawnSync([bin, "pane", "get", paneId], { stdout: "pipe", stderr: "ignore" });
|
|
26
|
+
if (got.exitCode !== 0) return false;
|
|
27
|
+
const parsed = JSON.parse(got.stdout.toString()) as { result?: { pane?: { tab_id?: string } } };
|
|
28
|
+
const tab = parsed.result?.pane?.tab_id;
|
|
29
|
+
if (!tab) return false;
|
|
30
|
+
return Bun.spawnSync([bin, "tab", "focus", tab], { stdout: "ignore", stderr: "ignore" }).exitCode === 0;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { filetypeFor, highlightCode } from "./syntax";
|
|
3
|
+
|
|
4
|
+
const TS = `export function gate(full: number) {
|
|
5
|
+
// threshold
|
|
6
|
+
return full < 0.6 ? "blocked" : "clear";
|
|
7
|
+
}`;
|
|
8
|
+
|
|
9
|
+
describe("highlightCode", () => {
|
|
10
|
+
test("token text concatenates back to the exact source (verbatim rendering)", async () => {
|
|
11
|
+
const lines = await highlightCode(TS, "ts");
|
|
12
|
+
expect(lines).not.toBeNull();
|
|
13
|
+
const rebuilt = lines!.map((l) => l.map((t) => t.content).join("")).join("\n");
|
|
14
|
+
expect(rebuilt).toBe(TS);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("keywords, strings, and comments get distinct colors", async () => {
|
|
18
|
+
const lines = await highlightCode(TS, "ts");
|
|
19
|
+
const colorOf = (needle: string) => {
|
|
20
|
+
for (const line of lines!) for (const t of line) if (t.content.includes(needle)) return t.color;
|
|
21
|
+
return undefined;
|
|
22
|
+
};
|
|
23
|
+
const kw = colorOf("export");
|
|
24
|
+
const str = colorOf("blocked");
|
|
25
|
+
const comment = colorOf("threshold");
|
|
26
|
+
expect(kw).toBeDefined();
|
|
27
|
+
expect(str).toBeDefined();
|
|
28
|
+
expect(comment).toBeDefined();
|
|
29
|
+
expect(new Set([kw, str, comment]).size).toBe(3);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("unknown languages return null and render unstyled", async () => {
|
|
33
|
+
expect(await highlightCode("whatever", "brainfog")).toBeNull();
|
|
34
|
+
expect(await highlightCode("plain", undefined)).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("fence aliases map to real grammars", async () => {
|
|
38
|
+
expect(await highlightCode("x = 1", "py")).not.toBeNull();
|
|
39
|
+
expect(await highlightCode('echo "hi"', "sh")).not.toBeNull();
|
|
40
|
+
});
|
|
41
|
+
});
|
package/src/syntax.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code-block syntax highlighting via Shiki (#67): TextMate-grammar tokens
|
|
3
|
+
* colored by a theme built from cueloop's tokens. The highlighter loads
|
|
4
|
+
* lazily on the first code block; unknown languages render unstyled.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { DARK as T } from "./theme";
|
|
8
|
+
|
|
9
|
+
export interface CodeToken {
|
|
10
|
+
content: string;
|
|
11
|
+
color?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Highlighter = {
|
|
15
|
+
codeToTokensBase(code: string, opts: { lang: string; theme: string }): { content: string; color?: string }[][];
|
|
16
|
+
getLoadedLanguages(): string[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const LANGS = ["typescript", "tsx", "javascript", "jsx", "json", "bash", "python", "rust", "go", "yaml", "diff", "markdown", "toml", "sql", "html", "css"];
|
|
20
|
+
|
|
21
|
+
/** Markdown fence info → Shiki language id. */
|
|
22
|
+
export function filetypeFor(lang?: string): string {
|
|
23
|
+
const l = (lang ?? "").toLowerCase();
|
|
24
|
+
const map: Record<string, string> = {
|
|
25
|
+
ts: "typescript",
|
|
26
|
+
js: "javascript",
|
|
27
|
+
py: "python",
|
|
28
|
+
rb: "ruby",
|
|
29
|
+
rs: "rust",
|
|
30
|
+
sh: "bash",
|
|
31
|
+
shell: "bash",
|
|
32
|
+
zsh: "bash",
|
|
33
|
+
yml: "yaml",
|
|
34
|
+
};
|
|
35
|
+
return map[l] ?? l;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cueloopTheme() {
|
|
39
|
+
const scope = (scopes: string, foreground: string, fontStyle?: string) => ({
|
|
40
|
+
scope: scopes.split(", "),
|
|
41
|
+
settings: fontStyle ? { foreground, fontStyle } : { foreground },
|
|
42
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
name: "cueloop",
|
|
45
|
+
type: "dark" as const,
|
|
46
|
+
colors: { "editor.background": T.elevated, "editor.foreground": T.textMuted },
|
|
47
|
+
settings: [
|
|
48
|
+
{ settings: { foreground: T.textMuted } },
|
|
49
|
+
scope("comment, punctuation.definition.comment", T.textDim, "italic"),
|
|
50
|
+
scope("string, string.quoted, punctuation.definition.string", T.green),
|
|
51
|
+
scope("constant.numeric, constant.language, constant.character", T.blue),
|
|
52
|
+
scope("keyword, storage, storage.type, storage.modifier, keyword.control", T.accent),
|
|
53
|
+
scope("entity.name.function, support.function, meta.function-call", T.text),
|
|
54
|
+
scope("entity.name.type, entity.name.class, support.type, support.class", T.blue),
|
|
55
|
+
scope("variable, variable.other, variable.parameter", T.textMuted),
|
|
56
|
+
scope("entity.name.tag", T.accent),
|
|
57
|
+
scope("entity.other.attribute-name", T.blue),
|
|
58
|
+
scope("punctuation, meta.brace", T.textDim),
|
|
59
|
+
scope("keyword.operator", T.textDim),
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let highlighterPromise: Promise<Highlighter | null> | null = null;
|
|
65
|
+
|
|
66
|
+
function getHighlighter(): Promise<Highlighter | null> {
|
|
67
|
+
highlighterPromise ??= import("shiki")
|
|
68
|
+
.then((shiki) => shiki.createHighlighter({ themes: [cueloopTheme()], langs: LANGS }) as Promise<Highlighter>)
|
|
69
|
+
.catch(() => null); // highlighting is an enhancement, never a blocker
|
|
70
|
+
return highlighterPromise;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Tokenize code into styled lines. Token text concatenates back to the exact
|
|
75
|
+
* source line, so verbatim rendering (indentation, no wrap) is preserved.
|
|
76
|
+
* Returns null when the language is unknown or the highlighter is unavailable.
|
|
77
|
+
*/
|
|
78
|
+
export async function highlightCode(content: string, lang?: string): Promise<CodeToken[][] | null> {
|
|
79
|
+
const language = filetypeFor(lang);
|
|
80
|
+
if (!language) return null;
|
|
81
|
+
const highlighter = await getHighlighter();
|
|
82
|
+
if (!highlighter || !highlighter.getLoadedLanguages().includes(language)) return null;
|
|
83
|
+
try {
|
|
84
|
+
return highlighter.codeToTokensBase(content, { lang: language, theme: "cueloop" });
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/view.test.ts
CHANGED
|
@@ -2,17 +2,15 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
blockRuns,
|
|
4
4
|
buildDisplay,
|
|
5
|
-
cutBlock,
|
|
6
5
|
displayText,
|
|
7
6
|
marksByDisplay,
|
|
7
|
+
nextWorkBlock,
|
|
8
8
|
overlayMarks,
|
|
9
|
-
restoreBlock,
|
|
10
|
-
restoreLine,
|
|
11
9
|
spanKey,
|
|
12
10
|
startSpan,
|
|
13
11
|
wrapRuns,
|
|
14
12
|
} from "./view";
|
|
15
|
-
import { parseBlocks, type Annotation } from "@cueloop/schema";
|
|
13
|
+
import { cutBlock, parseBlocks, restoreBlock, restoreLine, type Annotation } from "@cueloop/schema";
|
|
16
14
|
|
|
17
15
|
const BASE = `# Plan
|
|
18
16
|
|
|
@@ -150,8 +148,10 @@ describe("cut / restore round-trip", () => {
|
|
|
150
148
|
expect(cut).not.toContain("second item");
|
|
151
149
|
const display = buildDisplay(BASE, cut);
|
|
152
150
|
const delIdx = display.findIndex((d) => d.type === "del");
|
|
153
|
-
const line = restoreLine(display, delIdx, cut.split("\n").length);
|
|
154
|
-
|
|
151
|
+
const line = restoreLine(nextWorkBlock(display, delIdx), cut.split("\n").length);
|
|
152
|
+
// undefined = the working copy round-tripped back to the submitted revision
|
|
153
|
+
const restored = restoreBlock(BASE, cut, li, line);
|
|
154
|
+
expect(restored).toBeUndefined();
|
|
155
155
|
expect(buildDisplay(BASE, restored).every((d) => d.type === "same")).toBe(true);
|
|
156
156
|
});
|
|
157
157
|
});
|
package/src/view.ts
CHANGED
|
Binary file
|