@cueloop/client 0.1.0-alpha.0 → 0.1.0-alpha.11
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 +15 -9
- package/src/App.autoclose.test.tsx +118 -0
- package/src/App.export.test.tsx +10 -2
- package/src/App.plan-review.test.tsx +259 -0
- package/src/App.rendering.test.tsx +108 -0
- package/src/App.submit-confirm.test.tsx +183 -0
- package/src/App.test.tsx +6 -5
- 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/keymap.test.ts +270 -0
- package/src/keymap.ts +173 -0
- package/src/run.ts +3 -1
- package/src/session-controller.ts +398 -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
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rail submit confirm (tier 2): pressing submit expands the rail's
|
|
3
|
+
* Submit button into a bordered confirm card - counts, verdict selector,
|
|
4
|
+
* summary input, word-buttons. Char-frame assertions over the real App
|
|
5
|
+
* and an in-process daemon, like App.test.tsx.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import React from "react";
|
|
13
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
14
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
15
|
+
import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
|
|
16
|
+
import { App } from "./App";
|
|
17
|
+
|
|
18
|
+
const PLAN = `# Migration Plan
|
|
19
|
+
|
|
20
|
+
## Context
|
|
21
|
+
|
|
22
|
+
The daemon persists sessions to disk atomically.
|
|
23
|
+
|
|
24
|
+
## Steps
|
|
25
|
+
|
|
26
|
+
- move the store
|
|
27
|
+
- add recovery
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
let home: string;
|
|
31
|
+
let server: DaemonServer;
|
|
32
|
+
let session: ReviewSession;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-confirm-"));
|
|
36
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
37
|
+
server.start();
|
|
38
|
+
session = server.core.sessionCreate({
|
|
39
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
40
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
server.stop();
|
|
45
|
+
rmSync(home, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
async function renderApp(options: { readOnly?: boolean } = {}) {
|
|
49
|
+
const setup = await testRender(<App home={home} sessionId={session.id} readOnly={options.readOnly ?? false} />, {
|
|
50
|
+
width: 120,
|
|
51
|
+
height: 32,
|
|
52
|
+
});
|
|
53
|
+
for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
|
|
54
|
+
await Bun.sleep(25);
|
|
55
|
+
await setup.renderOnce();
|
|
56
|
+
}
|
|
57
|
+
await setup.renderOnce();
|
|
58
|
+
return setup;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
62
|
+
|
|
63
|
+
async function press(setup: Setup, k: string): Promise<void> {
|
|
64
|
+
if (k === "enter") setup.mockInput.pressKey("RETURN");
|
|
65
|
+
else if (k === "escape") {
|
|
66
|
+
setup.mockInput.pressKey("ESCAPE");
|
|
67
|
+
// a bare ESC byte is an ambiguous sequence prefix; the parser settles late
|
|
68
|
+
await Bun.sleep(120);
|
|
69
|
+
} else if (k === "left") setup.mockInput.pressKey("ARROW_LEFT");
|
|
70
|
+
else if (k === "right") setup.mockInput.pressKey("ARROW_RIGHT");
|
|
71
|
+
else await setup.mockInput.typeText(k);
|
|
72
|
+
await Bun.sleep(15);
|
|
73
|
+
await setup.renderOnce();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Seed annotations directly through the daemon core (all on one block). */
|
|
77
|
+
function seedAnnotations(count: number): void {
|
|
78
|
+
const blocks = parseBlocks(PLAN);
|
|
79
|
+
for (let index = 1; index <= count; index++) {
|
|
80
|
+
server.core.sessionAnnotate(session.id, {
|
|
81
|
+
id: `a_confirm_${index}`,
|
|
82
|
+
kind: "comment",
|
|
83
|
+
anchor: makeAnchor(blocks, 2, 0, 10),
|
|
84
|
+
body: `note ${String(index).padStart(2, "0")}`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("rail submit confirm", () => {
|
|
90
|
+
test("submit expands the Submit button into the confirm card", async () => {
|
|
91
|
+
const setup = await renderApp();
|
|
92
|
+
expect(setup.captureCharFrame()).toContain("Submit review (0)");
|
|
93
|
+
await press(setup, "enter");
|
|
94
|
+
const frame = setup.captureCharFrame();
|
|
95
|
+
// the card: title, honest counts, verdict selector, word-buttons
|
|
96
|
+
expect(frame).toContain("submit review");
|
|
97
|
+
expect(frame).toContain("0 annotations · 0 blocking");
|
|
98
|
+
expect(frame).toContain("[Approve]"); // nothing pending: approve default
|
|
99
|
+
expect(frame).toContain(" Submit ");
|
|
100
|
+
expect(frame).toContain(" Cancel ");
|
|
101
|
+
// key hints live in the status line, not on the buttons
|
|
102
|
+
expect(frame).toContain("verdict ←/→ · ⏎ submit · esc cancel");
|
|
103
|
+
// the bottom bar stayed a one-line hint: no detached verdict bar
|
|
104
|
+
expect(frame).not.toContain("Submit review (0) on ⏎");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("left/right cycles the verdict selector in the card", async () => {
|
|
108
|
+
const setup = await renderApp();
|
|
109
|
+
await press(setup, "enter");
|
|
110
|
+
expect(setup.captureCharFrame()).toContain("[Approve]");
|
|
111
|
+
await press(setup, "right");
|
|
112
|
+
expect(setup.captureCharFrame()).toContain("[Changes]");
|
|
113
|
+
await press(setup, "right");
|
|
114
|
+
expect(setup.captureCharFrame()).toContain("[Comment]");
|
|
115
|
+
await press(setup, "left");
|
|
116
|
+
expect(setup.captureCharFrame()).toContain("[Changes]");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("esc cancels the card and restores the plain Submit button", async () => {
|
|
120
|
+
const setup = await renderApp();
|
|
121
|
+
await press(setup, "enter");
|
|
122
|
+
expect(setup.captureCharFrame()).toContain("[Approve]");
|
|
123
|
+
await press(setup, "escape");
|
|
124
|
+
const frame = setup.captureCharFrame();
|
|
125
|
+
expect(frame).not.toContain("[Approve]");
|
|
126
|
+
expect(frame).not.toContain("0 annotations");
|
|
127
|
+
expect(frame).toContain("Submit review (0)");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("enter in the card resolves the session through the controller", async () => {
|
|
131
|
+
seedAnnotations(1);
|
|
132
|
+
const setup = await renderApp();
|
|
133
|
+
await press(setup, "enter");
|
|
134
|
+
const frame = setup.captureCharFrame();
|
|
135
|
+
expect(frame).toContain("1 annotations · 0 blocking");
|
|
136
|
+
expect(frame).toContain("[Changes]"); // pending items: request changes default
|
|
137
|
+
await setup.mockInput.typeText("Tighten the steps.");
|
|
138
|
+
await press(setup, "enter");
|
|
139
|
+
await Bun.sleep(120);
|
|
140
|
+
await setup.renderOnce();
|
|
141
|
+
const stored = server.core.sessionGet(session.id);
|
|
142
|
+
expect(stored.status).toBe("resolved");
|
|
143
|
+
expect(stored.verdict!.kind).toBe("request_changes");
|
|
144
|
+
// the completion flow after submit is unchanged
|
|
145
|
+
expect(setup.captureCharFrame()).toContain("✎ feedback sent");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("the annotation stack stays scrollable while the card is open", async () => {
|
|
149
|
+
seedAnnotations(12);
|
|
150
|
+
const setup = await renderApp();
|
|
151
|
+
// walk annotation focus to the last card: the rail scrolls it into view
|
|
152
|
+
for (let index = 0; index < 12; index++) await press(setup, "n");
|
|
153
|
+
const scrolled = setup.captureCharFrame();
|
|
154
|
+
expect(scrolled).toContain("note 12");
|
|
155
|
+
expect(scrolled).not.toContain("note 01");
|
|
156
|
+
await press(setup, "enter");
|
|
157
|
+
const opened = setup.captureCharFrame();
|
|
158
|
+
// the card is pinned outside the scrollbox; the stack above stays
|
|
159
|
+
// scrolled away from the top when the card takes its space
|
|
160
|
+
expect(opened).toContain("12 annotations · 0 blocking");
|
|
161
|
+
expect(opened).not.toContain("note 01");
|
|
162
|
+
// the stack still scrolls with the card open: wheel over the rail
|
|
163
|
+
// brings the last card back into view while the confirm card stays put
|
|
164
|
+
for (let turn = 0; turn < 12; turn++) await setup.mockMouse.scroll(100, 10, "down");
|
|
165
|
+
await setup.renderOnce();
|
|
166
|
+
const scrolledWithCard = setup.captureCharFrame();
|
|
167
|
+
expect(scrolledWithCard).toContain("note 12");
|
|
168
|
+
expect(scrolledWithCard).toContain("12 annotations · 0 blocking");
|
|
169
|
+
expect(scrolledWithCard).not.toContain("note 01");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("read-only observers never see the confirm card", async () => {
|
|
173
|
+
seedAnnotations(1);
|
|
174
|
+
const setup = await renderApp({ readOnly: true });
|
|
175
|
+
await press(setup, "enter");
|
|
176
|
+
const frame = setup.captureCharFrame();
|
|
177
|
+
expect(frame).toContain("observer - read-only");
|
|
178
|
+
expect(frame).not.toContain("1 annotations");
|
|
179
|
+
expect(frame).not.toContain("[Changes]");
|
|
180
|
+
expect(setup.captureCharFrame()).not.toContain("verdict ←/→");
|
|
181
|
+
expect(server.core.sessionGet(session.id).status).toBe("pending");
|
|
182
|
+
});
|
|
183
|
+
});
|
package/src/App.test.tsx
CHANGED
|
@@ -86,7 +86,7 @@ describe("plan rendering", () => {
|
|
|
86
86
|
expect(frame).toContain("Context");
|
|
87
87
|
expect(frame).toContain("persists sessions to disk atomically");
|
|
88
88
|
expect(frame).toContain("- move the store");
|
|
89
|
-
expect(frame).toContain("
|
|
89
|
+
expect(frame).toContain("Review (0)");
|
|
90
90
|
expect(frame).toContain("Submit review (0)");
|
|
91
91
|
});
|
|
92
92
|
});
|
|
@@ -108,7 +108,7 @@ describe("keyboard grammar", () => {
|
|
|
108
108
|
await press(setup, "j");
|
|
109
109
|
await press(setup, "c");
|
|
110
110
|
await setup.renderOnce();
|
|
111
|
-
expect(setup.captureCharFrame()).toContain("
|
|
111
|
+
expect(setup.captureCharFrame()).toContain('comment on "The daemon');
|
|
112
112
|
await type(setup, "Define atomically.");
|
|
113
113
|
await press(setup, "enter");
|
|
114
114
|
await Bun.sleep(80);
|
|
@@ -167,7 +167,7 @@ describe("keyboard grammar", () => {
|
|
|
167
167
|
});
|
|
168
168
|
|
|
169
169
|
describe("submit", () => {
|
|
170
|
-
test("⏎ opens the
|
|
170
|
+
test("⏎ opens the rail confirm card; verdict + summary resolve the session", async () => {
|
|
171
171
|
const setup = await renderApp();
|
|
172
172
|
await press(setup, "j");
|
|
173
173
|
await press(setup, "j");
|
|
@@ -177,7 +177,7 @@ describe("submit", () => {
|
|
|
177
177
|
await Bun.sleep(80);
|
|
178
178
|
await press(setup, "enter"); // open submit
|
|
179
179
|
await setup.renderOnce();
|
|
180
|
-
expect(setup.captureCharFrame()).toContain("[
|
|
180
|
+
expect(setup.captureCharFrame()).toContain("[Changes]");
|
|
181
181
|
await type(setup, "Expand the steps.");
|
|
182
182
|
await press(setup, "enter");
|
|
183
183
|
await Bun.sleep(120);
|
|
@@ -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,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keyboard grammar as data: every mode x key produces a known intent
|
|
3
|
+
* list, the read-only rule is one gate, and plan and diff reviews share one
|
|
4
|
+
* path for annotation navigation, deletion, and submit.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { DEFAULT_KEYS } from "./config";
|
|
9
|
+
import { reduceKey, type Intent, type KeyState } from "./keymap";
|
|
10
|
+
|
|
11
|
+
function state(patch: Partial<KeyState> = {}): KeyState {
|
|
12
|
+
return {
|
|
13
|
+
keys: DEFAULT_KEYS,
|
|
14
|
+
readOnly: false,
|
|
15
|
+
overlay: "none",
|
|
16
|
+
view: "plan",
|
|
17
|
+
spanMode: false,
|
|
18
|
+
resolved: false,
|
|
19
|
+
hasInboxItems: true,
|
|
20
|
+
annotationCount: 1,
|
|
21
|
+
hasFocusedAnnotation: true,
|
|
22
|
+
cursorAnnotatable: true,
|
|
23
|
+
...patch,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function key(name: string, shift = false): { name: string; shift: boolean } {
|
|
28
|
+
return { name, shift };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("compose overlay", () => {
|
|
32
|
+
const s = state({ overlay: "compose" });
|
|
33
|
+
const table: [string, Intent[]][] = [
|
|
34
|
+
["escape", [{ t: "closeOverlay" }]],
|
|
35
|
+
["return", [{ t: "saveCompose" }]],
|
|
36
|
+
["enter", [{ t: "saveCompose" }]],
|
|
37
|
+
["left", []],
|
|
38
|
+
["right", []],
|
|
39
|
+
["j", []],
|
|
40
|
+
["q", []],
|
|
41
|
+
];
|
|
42
|
+
for (const [name, expected] of table) {
|
|
43
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
44
|
+
expect(reduceKey(s, key(name))).toEqual(expected);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("submit overlay", () => {
|
|
50
|
+
const s = state({ overlay: "submit" });
|
|
51
|
+
const table: [string, Intent[]][] = [
|
|
52
|
+
["escape", [{ t: "closeOverlay" }]],
|
|
53
|
+
["return", [{ t: "submitVerdict" }]],
|
|
54
|
+
["enter", [{ t: "submitVerdict" }]],
|
|
55
|
+
["left", [{ t: "cycleVerdict", dir: -1 }]],
|
|
56
|
+
["right", [{ t: "cycleVerdict", dir: 1 }]],
|
|
57
|
+
["j", []],
|
|
58
|
+
["q", []],
|
|
59
|
+
];
|
|
60
|
+
for (const [name, expected] of table) {
|
|
61
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
62
|
+
expect(reduceKey(s, key(name))).toEqual(expected);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("completion overlay", () => {
|
|
68
|
+
const prompt = state({ overlay: "completion-prompt" });
|
|
69
|
+
const counting = state({ overlay: "completion-counting" });
|
|
70
|
+
const table: [KeyState, string, Intent[]][] = [
|
|
71
|
+
[prompt, "return", [{ t: "finishReview" }]],
|
|
72
|
+
[prompt, "enter", [{ t: "finishReview" }]],
|
|
73
|
+
[prompt, "q", [{ t: "finishReview" }]],
|
|
74
|
+
[prompt, "a", [{ t: "optInAutoClose" }]],
|
|
75
|
+
[prompt, "escape", [{ t: "dismissCompletion" }]],
|
|
76
|
+
[prompt, "j", []],
|
|
77
|
+
[counting, "return", [{ t: "finishReview" }]],
|
|
78
|
+
[counting, "q", [{ t: "finishReview" }]],
|
|
79
|
+
[counting, "a", []],
|
|
80
|
+
[counting, "escape", [{ t: "dismissCompletion" }]],
|
|
81
|
+
];
|
|
82
|
+
for (const [s, name, expected] of table) {
|
|
83
|
+
test(`${s.overlay} ${name} -> ${JSON.stringify(expected)}`, () => {
|
|
84
|
+
expect(reduceKey(s, key(name))).toEqual(expected);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("inbox mode", () => {
|
|
90
|
+
const s = state({ view: "inbox" });
|
|
91
|
+
const table: [string, Intent[]][] = [
|
|
92
|
+
["j", [{ t: "inboxMove", to: "down" }]],
|
|
93
|
+
["down", [{ t: "inboxMove", to: "down" }]],
|
|
94
|
+
["k", [{ t: "inboxMove", to: "up" }]],
|
|
95
|
+
["up", [{ t: "inboxMove", to: "up" }]],
|
|
96
|
+
["return", [{ t: "openSession" }]],
|
|
97
|
+
["enter", [{ t: "openSession" }]],
|
|
98
|
+
["q", [{ t: "exit" }]],
|
|
99
|
+
["c", []],
|
|
100
|
+
["escape", []],
|
|
101
|
+
];
|
|
102
|
+
for (const [name, expected] of table) {
|
|
103
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
104
|
+
expect(reduceKey(s, key(name))).toEqual(expected);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
test("an empty inbox swallows every non-quit key", () => {
|
|
109
|
+
const empty = state({ view: "inbox", hasInboxItems: false });
|
|
110
|
+
for (const name of ["j", "k", "down", "up", "return", "enter"]) {
|
|
111
|
+
expect(reduceKey(empty, key(name))).toEqual([]);
|
|
112
|
+
}
|
|
113
|
+
expect(reduceKey(empty, key("q"))).toEqual([{ t: "exit" }]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("plan normal mode", () => {
|
|
118
|
+
const s = state();
|
|
119
|
+
const table: [string, boolean, Intent[]][] = [
|
|
120
|
+
["j", false, [{ t: "move", to: "down" }]],
|
|
121
|
+
["down", false, [{ t: "move", to: "down" }]],
|
|
122
|
+
["k", false, [{ t: "move", to: "up" }]],
|
|
123
|
+
["up", false, [{ t: "move", to: "up" }]],
|
|
124
|
+
["g", false, [{ t: "move", to: "top" }]],
|
|
125
|
+
["g", true, [{ t: "move", to: "bottom" }]],
|
|
126
|
+
["v", false, [{ t: "startSpan" }]],
|
|
127
|
+
["c", false, [{ t: "openCompose", kind: "comment", from: "cursor" }]],
|
|
128
|
+
["s", false, [{ t: "openCompose", kind: "suggestion", from: "cursor" }]],
|
|
129
|
+
// the default state has a selected card, so Cut and edit act on the card
|
|
130
|
+
["x", false, [{ t: "removeAnnotation" }]],
|
|
131
|
+
["e", false, [{ t: "editCard" }]],
|
|
132
|
+
["n", false, [{ t: "nextAnn" }]],
|
|
133
|
+
["p", false, [{ t: "prevAnn" }]],
|
|
134
|
+
["backspace", false, [{ t: "removeAnnotation" }]],
|
|
135
|
+
["return", false, [{ t: "openSubmit" }]],
|
|
136
|
+
["q", false, [{ t: "exit" }]],
|
|
137
|
+
["escape", false, [{ t: "deselect" }]],
|
|
138
|
+
["z", false, []],
|
|
139
|
+
];
|
|
140
|
+
for (const [name, shift, expected] of table) {
|
|
141
|
+
test(`${shift ? "shift+" : ""}${name} -> ${JSON.stringify(expected)}`, () => {
|
|
142
|
+
expect(reduceKey(s, key(name, shift))).toEqual(expected);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
test("resolved sessions guard the mutating verbs", () => {
|
|
147
|
+
const r = state({ resolved: true });
|
|
148
|
+
for (const name of ["c", "s", "x", "e"]) {
|
|
149
|
+
expect(reduceKey(r, key(name))).toEqual([{ t: "status", msg: "review submitted - read-only" }]);
|
|
150
|
+
}
|
|
151
|
+
expect(reduceKey(r, key("return"))).toEqual([]);
|
|
152
|
+
expect(reduceKey(r, key("backspace"))).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("cut text cannot host a comment or a span", () => {
|
|
156
|
+
const cut = state({ cursorAnnotatable: false });
|
|
157
|
+
expect(reduceKey(cut, key("c"))).toEqual([{ t: "status", msg: "text is cut - restore it first" }]);
|
|
158
|
+
expect(reduceKey(cut, key("s"))).toEqual([{ t: "status", msg: "text is cut - restore it first" }]);
|
|
159
|
+
expect(reduceKey(cut, key("v"))).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("annotation navigation without annotations reports, delete without focus is silent", () => {
|
|
163
|
+
const none = state({ annotationCount: 0, hasFocusedAnnotation: false });
|
|
164
|
+
expect(reduceKey(none, key("n"))).toEqual([{ t: "status", msg: "no annotations" }]);
|
|
165
|
+
expect(reduceKey(none, key("p"))).toEqual([{ t: "status", msg: "no annotations" }]);
|
|
166
|
+
expect(reduceKey(none, key("backspace"))).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("without a selected card x cuts the block and e opens the editor", () => {
|
|
170
|
+
const unfocused = state({ hasFocusedAnnotation: false });
|
|
171
|
+
expect(reduceKey(unfocused, key("x"))).toEqual([{ t: "cut" }]);
|
|
172
|
+
expect(reduceKey(unfocused, key("e"))).toEqual([{ t: "edit" }]);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("span mode", () => {
|
|
177
|
+
const s = state({ spanMode: true });
|
|
178
|
+
const table: [string, Intent[]][] = [
|
|
179
|
+
["l", [{ t: "spanKey", name: "l" }]],
|
|
180
|
+
["h", [{ t: "spanKey", name: "h" }]],
|
|
181
|
+
["w", [{ t: "spanKey", name: "w" }]],
|
|
182
|
+
["b", [{ t: "spanKey", name: "b" }]],
|
|
183
|
+
["$", [{ t: "spanKey", name: "$" }]],
|
|
184
|
+
["0", [{ t: "spanKey", name: "0" }]],
|
|
185
|
+
["c", [{ t: "openCompose", kind: "comment", from: "span" }]],
|
|
186
|
+
["s", [{ t: "openCompose", kind: "suggestion", from: "span" }]],
|
|
187
|
+
["escape", [{ t: "closeOverlay" }]],
|
|
188
|
+
["q", [{ t: "exit" }]],
|
|
189
|
+
["j", []],
|
|
190
|
+
];
|
|
191
|
+
for (const [name, expected] of table) {
|
|
192
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
193
|
+
expect(reduceKey(s, key(name))).toEqual(expected);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("diff mode", () => {
|
|
199
|
+
const s = state({ view: "diff" });
|
|
200
|
+
const table: [string, Intent[]][] = [
|
|
201
|
+
["j", [{ t: "move", to: "down" }]],
|
|
202
|
+
["k", [{ t: "move", to: "up" }]],
|
|
203
|
+
["g", [{ t: "move", to: "top" }]],
|
|
204
|
+
["c", [{ t: "openCompose", kind: "comment", from: "cursor" }]],
|
|
205
|
+
["n", [{ t: "nextAnn" }]],
|
|
206
|
+
["p", [{ t: "prevAnn" }]],
|
|
207
|
+
["backspace", [{ t: "removeAnnotation" }]],
|
|
208
|
+
["return", [{ t: "openSubmit" }]],
|
|
209
|
+
["v", [{ t: "status", msg: "plan-only verb - diff review uses c on a line" }]],
|
|
210
|
+
["x", [{ t: "status", msg: "plan-only verb - diff review uses c on a line" }]],
|
|
211
|
+
["e", [{ t: "status", msg: "plan-only verb - diff review uses c on a line" }]],
|
|
212
|
+
["s", [{ t: "status", msg: "plan-only verb - diff review uses c on a line" }]],
|
|
213
|
+
["q", [{ t: "exit" }]],
|
|
214
|
+
];
|
|
215
|
+
for (const [name, expected] of table) {
|
|
216
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
217
|
+
expect(reduceKey(s, key(name))).toEqual(expected);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
test("comment guards: resolved first, then non-code rows", () => {
|
|
222
|
+
expect(reduceKey(state({ view: "diff", resolved: true }), key("c"))).toEqual([
|
|
223
|
+
{ t: "status", msg: "review submitted - read-only" },
|
|
224
|
+
]);
|
|
225
|
+
expect(reduceKey(state({ view: "diff", cursorAnnotatable: false }), key("c"))).toEqual([
|
|
226
|
+
{ t: "status", msg: "move to a code line to comment" },
|
|
227
|
+
]);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("duplicated-branch collapse", () => {
|
|
232
|
+
test("plan and diff produce the same intents for annotation nav, delete, and submit", () => {
|
|
233
|
+
for (const patch of [
|
|
234
|
+
{},
|
|
235
|
+
{ annotationCount: 0, hasFocusedAnnotation: false },
|
|
236
|
+
{ resolved: true },
|
|
237
|
+
] as Partial<KeyState>[]) {
|
|
238
|
+
for (const name of ["n", "p", "backspace", "return"]) {
|
|
239
|
+
const plan = reduceKey(state(patch), key(name));
|
|
240
|
+
const diff = reduceKey(state({ ...patch, view: "diff" }), key(name));
|
|
241
|
+
expect(diff).toEqual(plan);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("read-only filter", () => {
|
|
248
|
+
test("every mutating key answers observer - read-only, in plan and diff", () => {
|
|
249
|
+
for (const view of ["plan", "diff"] as const) {
|
|
250
|
+
const s = state({ view, readOnly: true });
|
|
251
|
+
for (const name of ["c", "s", "x", "e", "backspace", "return", "enter"]) {
|
|
252
|
+
expect(reduceKey(s, key(name))).toEqual([{ t: "status", msg: "observer - read-only" }]);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("navigation and annotation focus still work for observers", () => {
|
|
258
|
+
const s = state({ readOnly: true });
|
|
259
|
+
expect(reduceKey(s, key("j"))).toEqual([{ t: "move", to: "down" }]);
|
|
260
|
+
expect(reduceKey(s, key("n"))).toEqual([{ t: "nextAnn" }]);
|
|
261
|
+
expect(reduceKey(s, key("q"))).toEqual([{ t: "exit" }]);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("span-mode c/s are gated by key name even under a rebound keymap", () => {
|
|
265
|
+
const rebound = { ...DEFAULT_KEYS, comment: ["m"], suggest: ["t"] };
|
|
266
|
+
const s = state({ spanMode: true, readOnly: true, keys: rebound });
|
|
267
|
+
expect(reduceKey(s, key("c"))).toEqual([{ t: "status", msg: "observer - read-only" }]);
|
|
268
|
+
expect(reduceKey(s, key("s"))).toEqual([{ t: "status", msg: "observer - read-only" }]);
|
|
269
|
+
});
|
|
270
|
+
});
|