@cueloop/client 0.1.0-alpha.20 → 0.1.0-alpha.22
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.autoclose.test.tsx +29 -5
- package/src/App.diff.test.tsx +20 -0
- package/src/App.export.test.tsx +12 -8
- package/src/App.plan-review.test.tsx +182 -7
- package/src/App.readonly.test.tsx +19 -5
- package/src/App.rendering.test.tsx +21 -2
- package/src/App.review-panel.test.tsx +120 -0
- package/src/App.submit-confirm.test.tsx +57 -6
- package/src/App.test.tsx +62 -5
- package/src/App.tsx +147 -185
- package/src/App.walk.test.tsx +63 -8
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.tsx +66 -13
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +116 -0
- package/src/components/ReviewRail.tsx +30 -5
- package/src/components/__snapshots__/stories.test.tsx.snap +140 -0
- package/src/components/primitives/Card.tsx +2 -1
- package/src/components/primitives/Dialog.tsx +2 -1
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/stories-app.tsx +2 -1
- package/src/components/stories.test.tsx +5 -0
- package/src/config.test.ts +113 -1
- package/src/config.ts +58 -22
- package/src/editor.test.ts +24 -0
- package/src/herdr.test.ts +4 -0
- package/src/index.ts +2 -0
- package/src/integrations.test.ts +12 -0
- package/src/intent-dispatch.test.ts +213 -0
- package/src/intent-dispatch.ts +270 -0
- package/src/key-bindings.test.ts +16 -5
- package/src/keymap.test.ts +141 -9
- package/src/keymap.ts +54 -7
- package/src/review-panel.test.ts +111 -0
- package/src/review-panel.ts +76 -0
- package/src/serve.test.ts +7 -0
- package/src/session-controller.ts +39 -4
- package/src/share.ts +47 -0
- package/src/view-diff.test.ts +11 -0
- package/src/view-diff.ts +6 -6
- package/src/view-plan.test.ts +81 -0
- package/src/view-plan.ts +3 -0
- package/src/walk.test.ts +6 -0
package/src/keymap.test.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
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
|
-
*/
|
|
1
|
+
/** The keyboard grammar as data: every mode x key maps to a known intent list, gated once by the read-only rule. */
|
|
6
2
|
|
|
7
3
|
import { describe, expect, test } from "bun:test";
|
|
8
4
|
import { DEFAULT_KEYS } from "./config";
|
|
@@ -25,11 +21,12 @@ function state(patch: Partial<KeyState> = {}): KeyState {
|
|
|
25
21
|
};
|
|
26
22
|
}
|
|
27
23
|
|
|
28
|
-
function key(name: string, shift = false): { name: string; shift: boolean } {
|
|
29
|
-
return { name, shift };
|
|
24
|
+
function key(name: string, shift = false, meta = false): { name: string; shift: boolean; meta: boolean } {
|
|
25
|
+
return { name, shift, meta };
|
|
30
26
|
}
|
|
31
27
|
|
|
32
28
|
describe("compose overlay", () => {
|
|
29
|
+
// Arrange
|
|
33
30
|
const keyState = state({ overlay: "compose" });
|
|
34
31
|
const table: [string, Intent[]][] = [
|
|
35
32
|
["escape", [{ type: "closeOverlay" }]],
|
|
@@ -45,16 +42,26 @@ describe("compose overlay", () => {
|
|
|
45
42
|
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
46
43
|
});
|
|
47
44
|
}
|
|
45
|
+
|
|
46
|
+
// ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline in the composer, so the
|
|
47
|
+
// grammar must not save on them; only a bare ⏎ saves.
|
|
48
|
+
test("option/alt+return (meta) does not save - the textarea inserts a newline", () => {
|
|
49
|
+
expect(reduceKey(keyState, key("return", false, true))).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
test("shift+return does not save - the textarea inserts a newline", () => {
|
|
52
|
+
expect(reduceKey(keyState, key("return", true, false))).toEqual([]);
|
|
53
|
+
});
|
|
48
54
|
});
|
|
49
55
|
|
|
50
56
|
describe("submit overlay", () => {
|
|
57
|
+
// Arrange
|
|
51
58
|
const keyState = state({ overlay: "submit" });
|
|
52
59
|
const table: [string, Intent[]][] = [
|
|
53
60
|
["escape", [{ type: "closeOverlay" }]],
|
|
54
61
|
["return", [{ type: "submitVerdict" }]],
|
|
55
62
|
["enter", [{ type: "submitVerdict" }]],
|
|
56
|
-
["left", [{ type: "cycleVerdict",
|
|
57
|
-
["right", [{ type: "cycleVerdict",
|
|
63
|
+
["left", [{ type: "cycleVerdict", direction: -1 }]],
|
|
64
|
+
["right", [{ type: "cycleVerdict", direction: 1 }]],
|
|
58
65
|
["j", []],
|
|
59
66
|
["q", []],
|
|
60
67
|
];
|
|
@@ -66,6 +73,7 @@ describe("submit overlay", () => {
|
|
|
66
73
|
});
|
|
67
74
|
|
|
68
75
|
describe("completion overlay", () => {
|
|
76
|
+
// Arrange
|
|
69
77
|
const prompt = state({ overlay: "completion-prompt" });
|
|
70
78
|
const counting = state({ overlay: "completion-counting" });
|
|
71
79
|
const table: [KeyState, string, Intent[]][] = [
|
|
@@ -88,6 +96,7 @@ describe("completion overlay", () => {
|
|
|
88
96
|
});
|
|
89
97
|
|
|
90
98
|
describe("inbox mode", () => {
|
|
99
|
+
// Arrange
|
|
91
100
|
const keyState = state({ view: "inbox" });
|
|
92
101
|
const table: [string, Intent[]][] = [
|
|
93
102
|
["j", [{ type: "inboxMove", to: "down" }]],
|
|
@@ -107,7 +116,10 @@ describe("inbox mode", () => {
|
|
|
107
116
|
}
|
|
108
117
|
|
|
109
118
|
test("an empty inbox swallows every non-quit key", () => {
|
|
119
|
+
// Arrange
|
|
110
120
|
const empty = state({ view: "inbox", hasInboxItems: false });
|
|
121
|
+
|
|
122
|
+
// Assert
|
|
111
123
|
for (const name of ["j", "k", "down", "up", "return", "enter"]) {
|
|
112
124
|
expect(reduceKey(empty, key(name))).toEqual([]);
|
|
113
125
|
}
|
|
@@ -115,7 +127,63 @@ describe("inbox mode", () => {
|
|
|
115
127
|
});
|
|
116
128
|
});
|
|
117
129
|
|
|
130
|
+
describe("collaborator (share) capabilities", () => {
|
|
131
|
+
test("can annotate: comment opens the composer", () => {
|
|
132
|
+
// Arrange
|
|
133
|
+
const collab = state({ canEditPlan: false, canSubmitVerdict: false });
|
|
134
|
+
|
|
135
|
+
// Act / Assert
|
|
136
|
+
expect(reduceKey(collab, key("c"), "comment")).toEqual([{ type: "openCompose", kind: "comment", from: "cursor" }]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("can edit their own card: edit with a focused annotation rewrites it", () => {
|
|
140
|
+
// Arrange
|
|
141
|
+
const collab = state({ canEditPlan: false, hasFocusedAnnotation: true });
|
|
142
|
+
|
|
143
|
+
// Act / Assert
|
|
144
|
+
expect(reduceKey(collab, key("e"), "edit")).toEqual([{ type: "editCard" }]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("cannot edit the plan: cut/edit with no card is refused", () => {
|
|
148
|
+
// Arrange
|
|
149
|
+
const collab = state({ canEditPlan: false, hasFocusedAnnotation: false });
|
|
150
|
+
|
|
151
|
+
// Act / Assert
|
|
152
|
+
expect(reduceKey(collab, key("e"), "edit")).toEqual([{ type: "status", message: "shared plan - edit it in your own copy" }]);
|
|
153
|
+
expect(reduceKey(collab, key("c"), "cut")).toEqual([{ type: "status", message: "shared plan - edit it in your own copy" }]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("cannot submit a verdict: there is no agent on a share", () => {
|
|
157
|
+
// Arrange
|
|
158
|
+
const collab = state({ canSubmitVerdict: false });
|
|
159
|
+
|
|
160
|
+
// Act / Assert
|
|
161
|
+
expect(reduceKey(collab, key("x"), "submit")).toEqual([{ type: "status", message: "shared view - your notes save as you go; q to leave" }]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("cannot re-share: only the owner shares", () => {
|
|
165
|
+
// Arrange
|
|
166
|
+
const collab = state({ canShare: false });
|
|
167
|
+
|
|
168
|
+
// Act / Assert
|
|
169
|
+
expect(reduceKey(collab, key("S", true), "share")).toEqual([{ type: "status", message: "only the plan owner can share" }]);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("share", () => {
|
|
174
|
+
test("the owner's share key publishes", () => {
|
|
175
|
+
// Act / Assert
|
|
176
|
+
expect(reduceKey(state(), key("S", true), "share")).toEqual([{ type: "share" }]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("an observer cannot share", () => {
|
|
180
|
+
// Act / Assert
|
|
181
|
+
expect(reduceKey(state({ readOnly: true }), key("S", true), "share")).toEqual([{ type: "status", message: "observer - read-only" }]);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
118
185
|
describe("plan normal mode", () => {
|
|
186
|
+
// Arrange
|
|
119
187
|
const keyState = state();
|
|
120
188
|
const table: [string, boolean, Intent[]][] = [
|
|
121
189
|
["j", false, [{ type: "move", to: "down" }]],
|
|
@@ -145,7 +213,10 @@ describe("plan normal mode", () => {
|
|
|
145
213
|
}
|
|
146
214
|
|
|
147
215
|
test("resolved sessions guard the mutating verbs", () => {
|
|
216
|
+
// Arrange
|
|
148
217
|
const resolvedState = state({ resolved: true });
|
|
218
|
+
|
|
219
|
+
// Assert
|
|
149
220
|
for (const name of ["c", "s", "x", "e"]) {
|
|
150
221
|
expect(reduceKey(resolvedState, key(name))).toEqual([{ type: "status", message: "review submitted - read-only" }]);
|
|
151
222
|
}
|
|
@@ -154,27 +225,37 @@ describe("plan normal mode", () => {
|
|
|
154
225
|
});
|
|
155
226
|
|
|
156
227
|
test("cut text cannot host a comment or a span", () => {
|
|
228
|
+
// Arrange
|
|
157
229
|
const cut = state({ cursorAnnotatable: false });
|
|
230
|
+
|
|
231
|
+
// Assert
|
|
158
232
|
expect(reduceKey(cut, key("c"))).toEqual([{ type: "status", message: "text is cut - restore it first" }]);
|
|
159
233
|
expect(reduceKey(cut, key("s"))).toEqual([{ type: "status", message: "text is cut - restore it first" }]);
|
|
160
234
|
expect(reduceKey(cut, key("v"))).toEqual([]);
|
|
161
235
|
});
|
|
162
236
|
|
|
163
237
|
test("annotation navigation without annotations reports, delete without focus is silent", () => {
|
|
238
|
+
// Arrange
|
|
164
239
|
const none = state({ annotationCount: 0, hasFocusedAnnotation: false });
|
|
240
|
+
|
|
241
|
+
// Assert
|
|
165
242
|
expect(reduceKey(none, key("n"))).toEqual([{ type: "status", message: "no annotations" }]);
|
|
166
243
|
expect(reduceKey(none, key("p"))).toEqual([{ type: "status", message: "no annotations" }]);
|
|
167
244
|
expect(reduceKey(none, key("backspace"))).toEqual([]);
|
|
168
245
|
});
|
|
169
246
|
|
|
170
247
|
test("without a selected card x cuts the block and e opens the editor", () => {
|
|
248
|
+
// Arrange
|
|
171
249
|
const unfocused = state({ hasFocusedAnnotation: false });
|
|
250
|
+
|
|
251
|
+
// Assert
|
|
172
252
|
expect(reduceKey(unfocused, key("x"))).toEqual([{ type: "cut" }]);
|
|
173
253
|
expect(reduceKey(unfocused, key("e"))).toEqual([{ type: "edit" }]);
|
|
174
254
|
});
|
|
175
255
|
});
|
|
176
256
|
|
|
177
257
|
describe("span mode", () => {
|
|
258
|
+
// Arrange
|
|
178
259
|
const keyState = state({ spanMode: true });
|
|
179
260
|
const table: [string, Intent[]][] = [
|
|
180
261
|
["l", [{ type: "spanKey", name: "l" }]],
|
|
@@ -197,6 +278,7 @@ describe("span mode", () => {
|
|
|
197
278
|
});
|
|
198
279
|
|
|
199
280
|
describe("diff mode", () => {
|
|
281
|
+
// Arrange
|
|
200
282
|
const keyState = state({ view: "diff" });
|
|
201
283
|
const table: [string, Intent[]][] = [
|
|
202
284
|
["j", [{ type: "move", to: "down" }]],
|
|
@@ -220,6 +302,7 @@ describe("diff mode", () => {
|
|
|
220
302
|
}
|
|
221
303
|
|
|
222
304
|
test("comment guards: resolved first, then non-code rows", () => {
|
|
305
|
+
// Assert
|
|
223
306
|
expect(reduceKey(state({ view: "diff", resolved: true }), key("c"))).toEqual([
|
|
224
307
|
{ type: "status", message: "review submitted - read-only" },
|
|
225
308
|
]);
|
|
@@ -231,6 +314,7 @@ describe("diff mode", () => {
|
|
|
231
314
|
|
|
232
315
|
describe("guided walk", () => {
|
|
233
316
|
test("w enters the walk in diff view; resolved and read-only sessions answer", () => {
|
|
317
|
+
// Assert
|
|
234
318
|
expect(reduceKey(state({ view: "diff" }), key("w"))).toEqual([{ type: "walkStart" }]);
|
|
235
319
|
expect(reduceKey(state({ view: "diff", resolved: true }), key("w"))).toEqual([
|
|
236
320
|
{ type: "status", message: "review submitted - read-only" },
|
|
@@ -241,6 +325,7 @@ describe("guided walk", () => {
|
|
|
241
325
|
expect(reduceKey(state(), key("w"))).toEqual([{ type: "status", message: "the guided walk is a diff-review mode" }]);
|
|
242
326
|
});
|
|
243
327
|
|
|
328
|
+
// Arrange
|
|
244
329
|
const walking = state({ view: "diff", overlay: "walk" });
|
|
245
330
|
const table: [string, Intent[]][] = [
|
|
246
331
|
["]", [{ type: "walkForward" }]],
|
|
@@ -259,12 +344,53 @@ describe("guided walk", () => {
|
|
|
259
344
|
}
|
|
260
345
|
|
|
261
346
|
test("the end card's return leaves the walk and opens the submit confirm", () => {
|
|
347
|
+
// Arrange
|
|
262
348
|
const atEnd = state({ view: "diff", overlay: "walk", walkAtEnd: true });
|
|
349
|
+
|
|
350
|
+
// Assert
|
|
263
351
|
expect(reduceKey(atEnd, key("return"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
|
|
264
352
|
expect(reduceKey(atEnd, key("enter"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
|
|
265
353
|
});
|
|
266
354
|
});
|
|
267
355
|
|
|
356
|
+
describe("review panel controls", () => {
|
|
357
|
+
const table: [string, Intent[]][] = [
|
|
358
|
+
["b", [{ type: "cycleReviewPanel" }]],
|
|
359
|
+
["]", [{ type: "resizeReviewPanel", direction: 1 }]],
|
|
360
|
+
["[", [{ type: "resizeReviewPanel", direction: -1 }]],
|
|
361
|
+
];
|
|
362
|
+
for (const view of ["plan", "diff"] as const) {
|
|
363
|
+
for (const [name, expected] of table) {
|
|
364
|
+
test(`${view} ${name} -> ${JSON.stringify(expected)}`, () => {
|
|
365
|
+
expect(reduceKey(state({ view }), key(name))).toEqual(expected);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
test("observers may still collapse and resize the panel (it is view state, not a mutation)", () => {
|
|
371
|
+
// Arrange
|
|
372
|
+
const observer = state({ readOnly: true });
|
|
373
|
+
|
|
374
|
+
// Assert
|
|
375
|
+
expect(reduceKey(observer, key("b"))).toEqual([{ type: "cycleReviewPanel" }]);
|
|
376
|
+
expect(reduceKey(observer, key("]"))).toEqual([{ type: "resizeReviewPanel", direction: 1 }]);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("span mode still owns b as a span verb, not a panel cycle", () => {
|
|
380
|
+
// Assert
|
|
381
|
+
expect(reduceKey(state({ spanMode: true }), key("b"))).toEqual([{ type: "spanKey", name: "b" }]);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("the walk overlay keeps the brackets for stepping", () => {
|
|
385
|
+
// Arrange
|
|
386
|
+
const walking = state({ view: "diff", overlay: "walk" });
|
|
387
|
+
|
|
388
|
+
// Assert
|
|
389
|
+
expect(reduceKey(walking, key("]"))).toEqual([{ type: "walkForward" }]);
|
|
390
|
+
expect(reduceKey(walking, key("["))).toEqual([{ type: "walkBack" }]);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
268
394
|
describe("duplicated-branch collapse", () => {
|
|
269
395
|
test("plan and diff produce the same intents for annotation nav, delete, and submit", () => {
|
|
270
396
|
for (const patch of [
|
|
@@ -292,15 +418,21 @@ describe("read-only filter", () => {
|
|
|
292
418
|
});
|
|
293
419
|
|
|
294
420
|
test("navigation and annotation focus still work for observers", () => {
|
|
421
|
+
// Arrange
|
|
295
422
|
const keyState = state({ readOnly: true });
|
|
423
|
+
|
|
424
|
+
// Assert
|
|
296
425
|
expect(reduceKey(keyState, key("j"))).toEqual([{ type: "move", to: "down" }]);
|
|
297
426
|
expect(reduceKey(keyState, key("n"))).toEqual([{ type: "nextAnnotation" }]);
|
|
298
427
|
expect(reduceKey(keyState, key("q"))).toEqual([{ type: "exit" }]);
|
|
299
428
|
});
|
|
300
429
|
|
|
301
430
|
test("span-mode c/s are gated by key name even under a rebound keymap", () => {
|
|
431
|
+
// Arrange
|
|
302
432
|
const rebound = { ...DEFAULT_KEYS, comment: ["m"], suggest: ["t"] };
|
|
303
433
|
const keyState = state({ spanMode: true, readOnly: true, keys: rebound });
|
|
434
|
+
|
|
435
|
+
// Assert
|
|
304
436
|
expect(reduceKey(keyState, key("c"))).toEqual([{ type: "status", message: "observer - read-only" }]);
|
|
305
437
|
expect(reduceKey(keyState, key("s"))).toEqual([{ type: "status", message: "observer - read-only" }]);
|
|
306
438
|
});
|
package/src/keymap.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type Intent =
|
|
|
18
18
|
| { type: "spanKey"; name: string }
|
|
19
19
|
| { type: "openCompose"; kind: "comment" | "suggestion"; from: "cursor" | "span" }
|
|
20
20
|
| { type: "openSubmit" }
|
|
21
|
+
| { type: "share" }
|
|
21
22
|
| { type: "cut" }
|
|
22
23
|
| { type: "edit" }
|
|
23
24
|
| { type: "editCard" }
|
|
@@ -32,20 +33,33 @@ export type Intent =
|
|
|
32
33
|
| { type: "closeOverlay" }
|
|
33
34
|
| { type: "saveCompose" }
|
|
34
35
|
| { type: "submitVerdict" }
|
|
35
|
-
| { type: "cycleVerdict";
|
|
36
|
+
| { type: "cycleVerdict"; direction: -1 | 1 }
|
|
36
37
|
| { type: "finishReview" }
|
|
37
38
|
| { type: "optInAutoClose" }
|
|
38
|
-
| { type: "dismissCompletion" }
|
|
39
|
+
| { type: "dismissCompletion" }
|
|
40
|
+
| { type: "cycleReviewPanel" }
|
|
41
|
+
| { type: "resizeReviewPanel"; direction: -1 | 1 };
|
|
39
42
|
|
|
40
43
|
export interface KeyInput {
|
|
41
44
|
name: string;
|
|
42
45
|
shift: boolean;
|
|
46
|
+
/** Option/Alt arrives as `meta` in this terminal stack (never as a raw alt). */
|
|
47
|
+
meta?: boolean;
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
export interface KeyState {
|
|
46
51
|
/** Loaded keymap (config.ts): action -> key combos. */
|
|
47
52
|
keys: Record<string, string[]>;
|
|
48
53
|
readOnly: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Owner-only verbs a share collaborator lacks (undefined = owner, allowed).
|
|
56
|
+
* A collaborator annotates but cannot edit the plan (cut / $EDITOR runs on
|
|
57
|
+
* the gateway) or submit an agent verdict (there is no agent on a share).
|
|
58
|
+
*/
|
|
59
|
+
canEditPlan?: boolean;
|
|
60
|
+
canSubmitVerdict?: boolean;
|
|
61
|
+
/** Owner-only: publish the plan as a share. A collaborator never re-shares. */
|
|
62
|
+
canShare?: boolean;
|
|
49
63
|
/** Layer that owns keys before the grammar runs. */
|
|
50
64
|
overlay: "none" | "walk" | "compose" | "submit" | "completion-prompt" | "completion-counting";
|
|
51
65
|
view: "inbox" | "plan" | "diff";
|
|
@@ -62,7 +76,7 @@ export interface KeyState {
|
|
|
62
76
|
}
|
|
63
77
|
|
|
64
78
|
/** Verbs that write session state; an observer never reaches their handlers. */
|
|
65
|
-
const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit", "walk"]);
|
|
79
|
+
const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit", "walk", "share"]);
|
|
66
80
|
|
|
67
81
|
const SPAN_KEYS = ["l", "h", "w", "b", "$", "0"];
|
|
68
82
|
|
|
@@ -83,10 +97,17 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
83
97
|
if (state.overlay === "compose" || state.overlay === "submit") {
|
|
84
98
|
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
85
99
|
if (name === "return" || name === "enter") {
|
|
86
|
-
|
|
100
|
+
// In the composer, ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline - the
|
|
101
|
+
// focused textarea owns that; only a bare ⏎ saves. The submit overlay
|
|
102
|
+
// keeps its plain ⏎ submit.
|
|
103
|
+
if (state.overlay === "compose") {
|
|
104
|
+
if (key.shift || key.meta) return [];
|
|
105
|
+
return [{ type: "saveCompose" }];
|
|
106
|
+
}
|
|
107
|
+
return [{ type: "submitVerdict" }];
|
|
87
108
|
}
|
|
88
109
|
if (state.overlay === "submit" && (name === "left" || name === "right")) {
|
|
89
|
-
return [{ type: "cycleVerdict",
|
|
110
|
+
return [{ type: "cycleVerdict", direction: name === "left" ? -1 : 1 }];
|
|
90
111
|
}
|
|
91
112
|
return [];
|
|
92
113
|
}
|
|
@@ -116,12 +137,32 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
116
137
|
const mutating = MUTATING_ACTIONS.has(action ?? "") || (state.spanMode && (name === "c" || name === "s"));
|
|
117
138
|
if (state.readOnly && mutating) return status("observer - read-only");
|
|
118
139
|
|
|
140
|
+
// share is a session-level verb: it works from any view, owner only
|
|
141
|
+
if (action === "share") {
|
|
142
|
+
if (state.canShare === false) return status("only the plan owner can share");
|
|
143
|
+
return [{ type: "share" }];
|
|
144
|
+
}
|
|
145
|
+
|
|
119
146
|
if (state.view === "inbox") return inboxGrammar(state, name);
|
|
120
|
-
|
|
147
|
+
// span mode owns its single-letter keys (b slides the span back) before the
|
|
148
|
+
// review-panel controls claim them
|
|
121
149
|
if (state.spanMode) return spanGrammar(name);
|
|
150
|
+
// the review panel rides both plan and diff reviews; collapsing and resizing
|
|
151
|
+
// are view state, so the read-only gate above lets them through
|
|
152
|
+
const reviewPanel = reviewPanelGrammar(action);
|
|
153
|
+
if (reviewPanel) return reviewPanel;
|
|
154
|
+
if (state.view === "diff") return diffGrammar(state, action);
|
|
122
155
|
return planGrammar(state, action, name);
|
|
123
156
|
}
|
|
124
157
|
|
|
158
|
+
/** The review-panel controls: cycle the mode, widen and narrow the rail. */
|
|
159
|
+
function reviewPanelGrammar(action: string | undefined): Intent[] | null {
|
|
160
|
+
if (action === "review_cycle") return [{ type: "cycleReviewPanel" }];
|
|
161
|
+
if (action === "review_wider") return [{ type: "resizeReviewPanel", direction: 1 }];
|
|
162
|
+
if (action === "review_narrower") return [{ type: "resizeReviewPanel", direction: -1 }];
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
125
166
|
function inboxGrammar(state: KeyState, name: string): Intent[] {
|
|
126
167
|
if (!state.hasInboxItems) return [];
|
|
127
168
|
if (name === "j" || name === "down") return [{ type: "inboxMove", to: "down" }];
|
|
@@ -176,6 +217,8 @@ function planGrammar(state: KeyState, action: string | undefined, name: string):
|
|
|
176
217
|
// the document selects, the rail edits: with a card selected, Cut deletes
|
|
177
218
|
// the annotation and edit rewrites the card body in place
|
|
178
219
|
if (state.hasFocusedAnnotation) return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
|
|
220
|
+
// editing the plan itself is the owner's verb - a collaborator only annotates
|
|
221
|
+
if (state.canEditPlan === false) return status("shared plan - edit it in your own copy");
|
|
179
222
|
return [{ type: action }];
|
|
180
223
|
}
|
|
181
224
|
return annotationCluster(state, action) ?? [];
|
|
@@ -199,6 +242,10 @@ function annotationCluster(state: KeyState, action: string | undefined): Intent[
|
|
|
199
242
|
if (state.resolved || !state.hasFocusedAnnotation) return [];
|
|
200
243
|
return [{ type: "removeAnnotation" }];
|
|
201
244
|
}
|
|
202
|
-
if (action === "submit")
|
|
245
|
+
if (action === "submit") {
|
|
246
|
+
// a collaborator's notes union back as they go; there is no verdict to submit
|
|
247
|
+
if (state.canSubmitVerdict === false) return status("shared view - your notes save as you go; q to leave");
|
|
248
|
+
return state.resolved ? [] : [{ type: "openSubmit" }];
|
|
249
|
+
}
|
|
203
250
|
return null;
|
|
204
251
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
REVIEW_DEFAULT_WIDTH,
|
|
4
|
+
REVIEW_MAX_WIDTH,
|
|
5
|
+
REVIEW_MIN_WIDTH,
|
|
6
|
+
REVIEW_PLAN_MIN_WIDTH,
|
|
7
|
+
REVIEW_RESIZE_STEP,
|
|
8
|
+
clampWidth,
|
|
9
|
+
cycleReviewPanelMode,
|
|
10
|
+
maxWidthForTerminal,
|
|
11
|
+
resolveReviewWidth,
|
|
12
|
+
reviewRowsToDivider,
|
|
13
|
+
toggleReviewPanelMode,
|
|
14
|
+
widthFromMouseColumn,
|
|
15
|
+
} from "./review-panel";
|
|
16
|
+
|
|
17
|
+
describe("clampWidth", () => {
|
|
18
|
+
test("keeps a width inside the bounds untouched", () => {
|
|
19
|
+
expect(clampWidth(REVIEW_DEFAULT_WIDTH)).toBe(REVIEW_DEFAULT_WIDTH);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("clamps below the minimum and above the maximum", () => {
|
|
23
|
+
expect(clampWidth(REVIEW_MIN_WIDTH - 10)).toBe(REVIEW_MIN_WIDTH);
|
|
24
|
+
expect(clampWidth(REVIEW_MAX_WIDTH + 10)).toBe(REVIEW_MAX_WIDTH);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("rounds a fractional column to a whole one", () => {
|
|
28
|
+
expect(clampWidth(30.7)).toBe(31);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("honors explicit bounds", () => {
|
|
32
|
+
expect(clampWidth(100, 10, 20)).toBe(20);
|
|
33
|
+
expect(clampWidth(5, 10, 20)).toBe(10);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("cycleReviewPanelMode", () => {
|
|
38
|
+
test("cycles expanded -> compact -> hidden -> expanded", () => {
|
|
39
|
+
expect(cycleReviewPanelMode("expanded")).toBe("compact");
|
|
40
|
+
expect(cycleReviewPanelMode("compact")).toBe("hidden");
|
|
41
|
+
expect(cycleReviewPanelMode("hidden")).toBe("expanded");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("toggleReviewPanelMode", () => {
|
|
46
|
+
test("the chevron toggles only between expanded and compact, never hidden", () => {
|
|
47
|
+
expect(toggleReviewPanelMode("expanded")).toBe("compact");
|
|
48
|
+
expect(toggleReviewPanelMode("compact")).toBe("expanded");
|
|
49
|
+
// a hidden panel has no chevron; a stray toggle still opens it
|
|
50
|
+
expect(toggleReviewPanelMode("hidden")).toBe("expanded");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("widthFromMouseColumn", () => {
|
|
55
|
+
test("measures the rail from the mouse column to the right edge", () => {
|
|
56
|
+
// divider dragged to column 80 of a 120-wide terminal -> a 40-col rail
|
|
57
|
+
expect(widthFromMouseColumn(80, 120)).toBe(40);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("clamps the dragged width to the bounds", () => {
|
|
61
|
+
expect(widthFromMouseColumn(0, 120)).toBe(REVIEW_MAX_WIDTH);
|
|
62
|
+
expect(widthFromMouseColumn(119, 120)).toBe(REVIEW_MIN_WIDTH);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("cannot drag the rail wide enough to starve the plan on a narrow terminal", () => {
|
|
66
|
+
// dragging to the far left edge of a 60-col terminal still leaves the plan its minimum
|
|
67
|
+
expect(widthFromMouseColumn(0, 60)).toBe(60 - REVIEW_PLAN_MIN_WIDTH - 1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("the resize step widens and narrows within the clamp", () => {
|
|
71
|
+
expect(clampWidth(REVIEW_DEFAULT_WIDTH + REVIEW_RESIZE_STEP)).toBe(REVIEW_DEFAULT_WIDTH + REVIEW_RESIZE_STEP);
|
|
72
|
+
expect(clampWidth(REVIEW_MIN_WIDTH - REVIEW_RESIZE_STEP)).toBe(REVIEW_MIN_WIDTH);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("maxWidthForTerminal", () => {
|
|
77
|
+
test("allows the full rail on a wide terminal", () => {
|
|
78
|
+
expect(maxWidthForTerminal(120)).toBe(REVIEW_MAX_WIDTH);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("reserves the plan its minimum on a narrow terminal", () => {
|
|
82
|
+
// 60-col terminal: 30 for the plan and 1 for the divider leaves 29 for the rail
|
|
83
|
+
expect(maxWidthForTerminal(60)).toBe(60 - REVIEW_PLAN_MIN_WIDTH - 1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("never drops below the rail minimum, however narrow the terminal", () => {
|
|
87
|
+
expect(maxWidthForTerminal(40)).toBe(REVIEW_MIN_WIDTH);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("resolveReviewWidth", () => {
|
|
92
|
+
test("leaves a width that already fits untouched", () => {
|
|
93
|
+
expect(resolveReviewWidth(REVIEW_DEFAULT_WIDTH, 120)).toBe(REVIEW_DEFAULT_WIDTH);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("shrinks a wide persisted width so the plan keeps its room", () => {
|
|
97
|
+
// a saved 50-col rail on a 60-col terminal would leave the plan 9 cols; clamp it back
|
|
98
|
+
expect(resolveReviewWidth(REVIEW_MAX_WIDTH, 60)).toBe(60 - REVIEW_PLAN_MIN_WIDTH - 1);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("reviewRowsToDivider", () => {
|
|
103
|
+
test("builds one vertical glyph per row", () => {
|
|
104
|
+
expect(reviewRowsToDivider(3)).toBe("│\n│\n│");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("never collapses to an empty column", () => {
|
|
108
|
+
expect(reviewRowsToDivider(0)).toBe("│");
|
|
109
|
+
expect(reviewRowsToDivider(-4)).toBe("│");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure geometry and state for the resizable review panel. The panel shows the
|
|
3
|
+
* review rail in one of three modes and the expanded rail resizes within a
|
|
4
|
+
* clamp; App owns the React state and the mouse wiring, this module owns the
|
|
5
|
+
* arithmetic so the layout math stays unit-tested and out of the JSX. Bounds
|
|
6
|
+
* and the compact width carry over verbatim from the approved prototype.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Expanded (full rail), compact (count + dots strip), or hidden (nothing). */
|
|
10
|
+
export type ReviewPanelMode = "expanded" | "compact" | "hidden";
|
|
11
|
+
|
|
12
|
+
/** Expanded-rail width bounds, in terminal columns. */
|
|
13
|
+
export const REVIEW_MIN_WIDTH = 24;
|
|
14
|
+
export const REVIEW_MAX_WIDTH = 50;
|
|
15
|
+
export const REVIEW_DEFAULT_WIDTH = 34;
|
|
16
|
+
/**
|
|
17
|
+
* Columns the plan/diff column keeps for itself: the expanded rail yields past
|
|
18
|
+
* this so a narrow terminal (or a wide persisted width) can never crowd the
|
|
19
|
+
* plan out to nothing - the whole reason the panel is resizable. On terminals
|
|
20
|
+
* wide enough for the full 50-col rail this bound never binds.
|
|
21
|
+
*/
|
|
22
|
+
export const REVIEW_PLAN_MIN_WIDTH = 30;
|
|
23
|
+
/** The compact strip is a fixed narrow column: count on top, one dot per card. */
|
|
24
|
+
export const REVIEW_COMPACT_WIDTH = 6;
|
|
25
|
+
/** Keyboard resize step, in columns, for the widen/narrow keys. */
|
|
26
|
+
export const REVIEW_RESIZE_STEP = 2;
|
|
27
|
+
|
|
28
|
+
/** Round a desired width to a whole column and clamp it to the bounds. */
|
|
29
|
+
export function clampWidth(desiredWidth: number, min = REVIEW_MIN_WIDTH, max = REVIEW_MAX_WIDTH): number {
|
|
30
|
+
return Math.max(min, Math.min(max, Math.round(desiredWidth)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The widest the expanded rail may be on a given terminal, reserving the plan
|
|
35
|
+
* its minimum and one column for the divider. Never drops below REVIEW_MIN_WIDTH
|
|
36
|
+
* (an extremely narrow terminal is the auto-collapse case, handled elsewhere).
|
|
37
|
+
*/
|
|
38
|
+
export function maxWidthForTerminal(terminalWidth: number): number {
|
|
39
|
+
return Math.max(REVIEW_MIN_WIDTH, Math.min(REVIEW_MAX_WIDTH, terminalWidth - REVIEW_PLAN_MIN_WIDTH - 1));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Clamp a desired rail width to the bounds AND the terminal, so the plan keeps its room. */
|
|
43
|
+
export function resolveReviewWidth(desiredWidth: number, terminalWidth: number): number {
|
|
44
|
+
return clampWidth(desiredWidth, REVIEW_MIN_WIDTH, maxWidthForTerminal(terminalWidth));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The keybinding cycle: expanded -> compact -> hidden -> expanded. */
|
|
48
|
+
export function cycleReviewPanelMode(mode: ReviewPanelMode): ReviewPanelMode {
|
|
49
|
+
return mode === "expanded" ? "compact" : mode === "compact" ? "hidden" : "expanded";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The clickable chevron toggles only between expanded and compact - hidden is
|
|
54
|
+
* reachable by keybinding alone, so it never leaves a leftover tab behind. A
|
|
55
|
+
* toggle from hidden still opens the panel rather than doing nothing.
|
|
56
|
+
*/
|
|
57
|
+
export function toggleReviewPanelMode(mode: ReviewPanelMode): ReviewPanelMode {
|
|
58
|
+
return mode === "expanded" ? "compact" : "expanded";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Rail width from an absolute mouse column while dragging the divider: the rail
|
|
63
|
+
* spans from the mouse column to the terminal's right edge, then clamps.
|
|
64
|
+
*/
|
|
65
|
+
export function widthFromMouseColumn(mouseColumn: number, terminalWidth: number): number {
|
|
66
|
+
return resolveReviewWidth(terminalWidth - mouseColumn, terminalWidth);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The divider is a single-column stack of vertical glyphs (a text block, not a
|
|
71
|
+
* bordered box - a one-column box with a border renders a full box). One glyph
|
|
72
|
+
* per row, at least one so the column never collapses.
|
|
73
|
+
*/
|
|
74
|
+
export function reviewRowsToDivider(rows: number): string {
|
|
75
|
+
return Array.from({ length: Math.max(1, rows) }, () => "│").join("\n");
|
|
76
|
+
}
|
package/src/serve.test.ts
CHANGED
|
@@ -70,6 +70,7 @@ function sshCapture(port: number, until: (frame: string) => boolean, timeoutMs =
|
|
|
70
70
|
|
|
71
71
|
describe("cueloop serve", () => {
|
|
72
72
|
test("listens on localhost, persists a host key under CUELOOP_HOME/ssh", () => {
|
|
73
|
+
// Assert
|
|
73
74
|
expect(handle.host).toBe("127.0.0.1");
|
|
74
75
|
expect(handle.port).toBeGreaterThan(0);
|
|
75
76
|
expect(handle.fingerprints.length).toBe(1);
|
|
@@ -78,7 +79,10 @@ describe("cueloop serve", () => {
|
|
|
78
79
|
});
|
|
79
80
|
|
|
80
81
|
test("an anonymous ssh client gets the observer TUI for the session", async () => {
|
|
82
|
+
// Act
|
|
81
83
|
const bytes = await sshCapture(handle.port, (frame) => frame.includes("Rollout Plan") && frame.includes("observer"));
|
|
84
|
+
|
|
85
|
+
// Assert
|
|
82
86
|
expect(bytes).toContain("cueloop");
|
|
83
87
|
expect(bytes).toContain("Rollout Plan");
|
|
84
88
|
// observer chrome, not the controller's mutating hint bar
|
|
@@ -86,10 +90,13 @@ describe("cueloop serve", () => {
|
|
|
86
90
|
});
|
|
87
91
|
|
|
88
92
|
test("two observers can watch at once", async () => {
|
|
93
|
+
// Act
|
|
89
94
|
const [a, b] = await Promise.all([
|
|
90
95
|
sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
|
|
91
96
|
sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
|
|
92
97
|
]);
|
|
98
|
+
|
|
99
|
+
// Assert
|
|
93
100
|
expect(a).toContain("Rollout Plan");
|
|
94
101
|
expect(b).toContain("Rollout Plan");
|
|
95
102
|
});
|