@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.9

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.
@@ -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
+ });
package/src/keymap.ts ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The keyboard grammar as a pure reducer (#70): view state in, intents out.
3
+ * App builds a KeyState per key event, calls reduceKey, and dispatches the
4
+ * intents to controller verbs and view-state setters - no branch bodies live
5
+ * in the key handler. Diff and plan reviews share one path for annotation
6
+ * navigation, deletion, and submit; the read-only rule is one gate here.
7
+ */
8
+
9
+ import { actionFor } from "./config";
10
+
11
+ export type Intent =
12
+ | { t: "exit" }
13
+ | { t: "status"; msg: string }
14
+ | { t: "move"; to: "down" | "up" | "top" | "bottom" }
15
+ | { t: "inboxMove"; to: "down" | "up" }
16
+ | { t: "openSession" }
17
+ | { t: "startSpan" }
18
+ | { t: "spanKey"; name: string }
19
+ | { t: "openCompose"; kind: "comment" | "suggestion"; from: "cursor" | "span" }
20
+ | { t: "openSubmit" }
21
+ | { t: "cut" }
22
+ | { t: "edit" }
23
+ | { t: "editCard" }
24
+ | { t: "nextAnn" }
25
+ | { t: "prevAnn" }
26
+ | { t: "removeAnnotation" }
27
+ | { t: "deselect" }
28
+ | { t: "closeOverlay" }
29
+ | { t: "saveCompose" }
30
+ | { t: "submitVerdict" }
31
+ | { t: "cycleVerdict"; dir: -1 | 1 }
32
+ | { t: "finishReview" }
33
+ | { t: "optInAutoClose" }
34
+ | { t: "dismissCompletion" };
35
+
36
+ export interface KeyInput {
37
+ name: string;
38
+ shift: boolean;
39
+ }
40
+
41
+ export interface KeyState {
42
+ /** Loaded keymap (config.ts): action -> key combos. */
43
+ keys: Record<string, string[]>;
44
+ readOnly: boolean;
45
+ /** Layer that owns keys before the grammar runs. */
46
+ overlay: "none" | "compose" | "submit" | "completion-prompt" | "completion-counting";
47
+ view: "inbox" | "plan" | "diff";
48
+ /** Plan-only span selection sub-mode. */
49
+ spanMode: boolean;
50
+ resolved: boolean;
51
+ hasInboxItems: boolean;
52
+ annotationCount: number;
53
+ hasFocusedAnnotation: boolean;
54
+ /** Cursor sits on annotatable text: a work block (plan) or a code row (diff). */
55
+ cursorAnnotatable: boolean;
56
+ }
57
+
58
+ /** Verbs that write session state; an observer never reaches their handlers. */
59
+ const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit"]);
60
+
61
+ const SPAN_KEYS = ["l", "h", "w", "b", "$", "0"];
62
+
63
+ function status(msg: string): Intent[] {
64
+ return [{ t: "status", msg }];
65
+ }
66
+
67
+ export function reduceKey(state: KeyState, key: KeyInput): Intent[] {
68
+ const name = key.name;
69
+ // compose/submit overlays own the keys via focused inputs; only escape,
70
+ // return, and verdict arrows route through the grammar
71
+ if (state.overlay === "compose" || state.overlay === "submit") {
72
+ if (name === "escape") return [{ t: "closeOverlay" }];
73
+ if (name === "return" || name === "enter") {
74
+ return [state.overlay === "compose" ? { t: "saveCompose" } : { t: "submitVerdict" }];
75
+ }
76
+ if (state.overlay === "submit" && (name === "left" || name === "right")) {
77
+ return [{ t: "cycleVerdict", dir: name === "left" ? -1 : 1 }];
78
+ }
79
+ return [];
80
+ }
81
+ if (state.overlay === "completion-prompt" || state.overlay === "completion-counting") {
82
+ if (name === "return" || name === "enter" || name === "q") return [{ t: "finishReview" }];
83
+ if (name === "a" && state.overlay === "completion-prompt") return [{ t: "optInAutoClose" }];
84
+ if (name === "escape") return [{ t: "dismissCompletion" }];
85
+ return [];
86
+ }
87
+ const action = actionFor(state.keys, name, key.shift);
88
+ if (action === "quit") return [{ t: "exit" }];
89
+ // the ONE read-only rule: any mutating attempt answers instead of acting
90
+ // (span-mode c/s are hardwired keys, so they gate by name as well)
91
+ const mutating = MUTATING_ACTIONS.has(action ?? "") || (state.spanMode && (name === "c" || name === "s"));
92
+ if (state.readOnly && mutating) return status("observer - read-only");
93
+
94
+ if (state.view === "inbox") return inboxGrammar(state, name);
95
+ if (state.view === "diff") return diffGrammar(state, action);
96
+ if (state.spanMode) return spanGrammar(name);
97
+ return planGrammar(state, action, name);
98
+ }
99
+
100
+ function inboxGrammar(state: KeyState, name: string): Intent[] {
101
+ if (!state.hasInboxItems) return [];
102
+ if (name === "j" || name === "down") return [{ t: "inboxMove", to: "down" }];
103
+ if (name === "k" || name === "up") return [{ t: "inboxMove", to: "up" }];
104
+ if (name === "return" || name === "enter") return [{ t: "openSession" }];
105
+ return [];
106
+ }
107
+
108
+ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
109
+ const nav = navIntent(action);
110
+ if (nav) return nav;
111
+ if (action === "comment") {
112
+ if (state.resolved) return status("review submitted - read-only");
113
+ if (!state.cursorAnnotatable) return status("move to a code line to comment");
114
+ return [{ t: "openCompose", kind: "comment", from: "cursor" }];
115
+ }
116
+ const shared = annotationCluster(state, action);
117
+ if (shared) return shared;
118
+ if (action === "span" || action === "cut" || action === "edit" || action === "suggest") {
119
+ return status("plan-only verb - diff review uses c on a line");
120
+ }
121
+ return [];
122
+ }
123
+
124
+ function spanGrammar(name: string): Intent[] {
125
+ if (name === "escape") return [{ t: "closeOverlay" }];
126
+ if (SPAN_KEYS.includes(name)) return [{ t: "spanKey", name }];
127
+ if (name === "c" || name === "s") {
128
+ return [{ t: "openCompose", kind: name === "s" ? "suggestion" : "comment", from: "span" }];
129
+ }
130
+ return [];
131
+ }
132
+
133
+ function planGrammar(state: KeyState, action: string | undefined, name: string): Intent[] {
134
+ const nav = navIntent(action);
135
+ if (nav) return nav;
136
+ if (name === "escape") return [{ t: "deselect" }];
137
+ if (action === "span") return state.cursorAnnotatable ? [{ t: "startSpan" }] : [];
138
+ if (action === "comment" || action === "suggest") {
139
+ if (state.resolved) return status("review submitted - read-only");
140
+ if (!state.cursorAnnotatable) return status("text is cut - restore it first");
141
+ return [{ t: "openCompose", kind: action === "suggest" ? "suggestion" : "comment", from: "cursor" }];
142
+ }
143
+ if (action === "cut" || action === "edit") {
144
+ if (state.resolved) return status("review submitted - read-only");
145
+ // the document selects, the rail edits: with a card selected, Cut deletes
146
+ // the annotation and edit rewrites the card body in place
147
+ if (state.hasFocusedAnnotation) return [action === "cut" ? { t: "removeAnnotation" } : { t: "editCard" }];
148
+ return [{ t: action }];
149
+ }
150
+ return annotationCluster(state, action) ?? [];
151
+ }
152
+
153
+ /** Cursor movement is the same intent everywhere; views clamp their own bounds. */
154
+ function navIntent(action: string | undefined): Intent[] | null {
155
+ if (action === "down" || action === "up" || action === "top" || action === "bottom") {
156
+ return [{ t: "move", to: action }];
157
+ }
158
+ return null;
159
+ }
160
+
161
+ /** Annotation navigation, deletion, and submit: one path for plan and diff. */
162
+ function annotationCluster(state: KeyState, action: string | undefined): Intent[] | null {
163
+ if (action === "next_annotation" || action === "prev_annotation") {
164
+ if (!state.annotationCount) return status("no annotations");
165
+ return [{ t: action === "next_annotation" ? "nextAnn" : "prevAnn" }];
166
+ }
167
+ if (action === "delete_annotation") {
168
+ if (state.resolved || !state.hasFocusedAnnotation) return [];
169
+ return [{ t: "removeAnnotation" }];
170
+ }
171
+ if (action === "submit") return state.resolved ? [] : [{ t: "openSubmit" }];
172
+ return null;
173
+ }
package/src/run.ts CHANGED
@@ -11,7 +11,9 @@ export interface RunClientOptions {
11
11
  }
12
12
 
13
13
  export async function runClient(opts: RunClientOptions): Promise<number> {
14
- const renderer = await createCliRenderer();
14
+ // mouse movement reporting makes multiplexers forward drags to the app,
15
+ // so the renderer's native selection is the drag driver
16
+ const renderer = await createCliRenderer({ enableMouseMovement: true });
15
17
  return new Promise<number>((resolve) => {
16
18
  createRoot(renderer).render(
17
19
  React.createElement(App, {