@cueloop/client 0.1.0-alpha.21 → 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 +121 -7
- package/src/App.readonly.test.tsx +19 -5
- package/src/App.rendering.test.tsx +15 -0
- package/src/App.review-panel.test.tsx +29 -6
- package/src/App.submit-confirm.test.tsx +57 -6
- package/src/App.test.tsx +62 -5
- package/src/App.tsx +71 -182
- package/src/App.walk.test.tsx +63 -8
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- package/src/components/ReviewRail.tsx +16 -4
- package/src/components/stories.test.tsx +5 -0
- package/src/config.test.ts +62 -0
- package/src/config.ts +1 -0
- 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 +97 -5
- package/src/keymap.ts +24 -2
- package/src/serve.test.ts +7 -0
- package/src/session-controller.ts +22 -3
- 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
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The intent reducer: one effectful handler per keymap Intent, lifted out of
|
|
3
|
+
* the App component so the 30-case grammar can be read and tested on its own.
|
|
4
|
+
* keymap.ts turns keys into Intents (pure); this turns an Intent into the
|
|
5
|
+
* matching controller call or view-state update. App builds the dependency
|
|
6
|
+
* bag once per render and hands it here; nothing in this module reaches back
|
|
7
|
+
* into React beyond the setters it is given.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react";
|
|
11
|
+
import { isAddressed, isAgentNote, type ReviewSession, type VerdictKind } from "@cueloop/schema";
|
|
12
|
+
import { displayText, spanKey, startSpan, type DisplayBlock, type SpanState } from "./view-plan";
|
|
13
|
+
import type { DiffRow } from "./view-diff";
|
|
14
|
+
import type { ReviewController } from "./session-controller";
|
|
15
|
+
import type { Intent } from "./keymap";
|
|
16
|
+
import type { PlanSheetHandle } from "./components/PlanSheet";
|
|
17
|
+
import { VERDICTS } from "./components/ConfirmCard";
|
|
18
|
+
import { REVIEW_RESIZE_STEP, cycleReviewPanelMode, resolveReviewWidth, type ReviewPanelMode } from "./review-panel";
|
|
19
|
+
|
|
20
|
+
/** The one overlay/mode the TUI is in; every compose/submit/edit flow is a variant. */
|
|
21
|
+
export type Mode =
|
|
22
|
+
| { type: "normal" }
|
|
23
|
+
| { type: "span"; span: SpanState }
|
|
24
|
+
| { type: "compose"; kind: "comment" | "suggestion"; displayIndex: number; start: number; end: number; text: string }
|
|
25
|
+
| { type: "railEdit"; id: string; text: string }
|
|
26
|
+
| { type: "submit"; verdict: VerdictKind; summary: string };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Annotations that still count as feedback: agent notes never do, and an
|
|
30
|
+
* annotation a revision already addressed is settled - it neither blocks the
|
|
31
|
+
* verdict default nor re-enters the next feedback document.
|
|
32
|
+
*/
|
|
33
|
+
export function reviewerAnnotations(session: ReviewSession) {
|
|
34
|
+
return session.annotations.filter((annotation) => !isAgentNote(annotation) && !isAddressed(annotation));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function defaultVerdict(session: ReviewSession): VerdictKind {
|
|
38
|
+
return reviewerAnnotations(session).length || session.workingCopy !== undefined ? "request_changes" : "approve";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Everything the reducer reads or writes, supplied fresh each render: the
|
|
43
|
+
* controller and exit hook, the derived reads (view rows, cursor, mode,
|
|
44
|
+
* session), the refs it pokes, the state setters, and the three App-owned
|
|
45
|
+
* selection helpers it composes.
|
|
46
|
+
*/
|
|
47
|
+
export interface IntentDispatchDeps {
|
|
48
|
+
controller: ReviewController;
|
|
49
|
+
onExit?: (code: number) => void;
|
|
50
|
+
|
|
51
|
+
isDiff: boolean;
|
|
52
|
+
display: DisplayBlock[];
|
|
53
|
+
rows: DiffRow[];
|
|
54
|
+
cursor: number;
|
|
55
|
+
inbox: ReviewSession[] | null;
|
|
56
|
+
inboxCursor: number;
|
|
57
|
+
mode: Mode;
|
|
58
|
+
session: ReviewSession | null;
|
|
59
|
+
reviewMode: ReviewPanelMode;
|
|
60
|
+
reviewWidth: number;
|
|
61
|
+
terminalWidth: number;
|
|
62
|
+
focusedAnnotationId: string | undefined;
|
|
63
|
+
|
|
64
|
+
liveInput: MutableRefObject<string>;
|
|
65
|
+
reviewWidthRef: MutableRefObject<number>;
|
|
66
|
+
planSheetRef: RefObject<PlanSheetHandle | null>;
|
|
67
|
+
|
|
68
|
+
setCursor: Dispatch<SetStateAction<number>>;
|
|
69
|
+
setInboxCursor: Dispatch<SetStateAction<number>>;
|
|
70
|
+
setMode: Dispatch<SetStateAction<Mode>>;
|
|
71
|
+
setReviewMode: Dispatch<SetStateAction<ReviewPanelMode>>;
|
|
72
|
+
setReviewWidth: Dispatch<SetStateAction<number>>;
|
|
73
|
+
setRailTab: Dispatch<SetStateAction<"review" | "agent">>;
|
|
74
|
+
setFocusedAnnotationId: Dispatch<SetStateAction<string | undefined>>;
|
|
75
|
+
setPulsedAnnotationId: Dispatch<SetStateAction<string | null>>;
|
|
76
|
+
|
|
77
|
+
selectCardFromDocument: (annotationId: string) => void;
|
|
78
|
+
runEditorHandOff: () => void;
|
|
79
|
+
openCardEdit: (annotationId: string) => void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Build the intent handler for one render from its dependency bag. */
|
|
83
|
+
export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent) => void {
|
|
84
|
+
const {
|
|
85
|
+
controller,
|
|
86
|
+
onExit,
|
|
87
|
+
isDiff,
|
|
88
|
+
display,
|
|
89
|
+
rows,
|
|
90
|
+
cursor,
|
|
91
|
+
inbox,
|
|
92
|
+
inboxCursor,
|
|
93
|
+
mode,
|
|
94
|
+
session,
|
|
95
|
+
reviewMode,
|
|
96
|
+
reviewWidth,
|
|
97
|
+
terminalWidth,
|
|
98
|
+
focusedAnnotationId,
|
|
99
|
+
liveInput,
|
|
100
|
+
reviewWidthRef,
|
|
101
|
+
planSheetRef,
|
|
102
|
+
setCursor,
|
|
103
|
+
setInboxCursor,
|
|
104
|
+
setMode,
|
|
105
|
+
setReviewMode,
|
|
106
|
+
setReviewWidth,
|
|
107
|
+
setRailTab,
|
|
108
|
+
setFocusedAnnotationId,
|
|
109
|
+
setPulsedAnnotationId,
|
|
110
|
+
selectCardFromDocument,
|
|
111
|
+
runEditorHandOff,
|
|
112
|
+
openCardEdit,
|
|
113
|
+
} = deps;
|
|
114
|
+
|
|
115
|
+
return (intent: Intent): void => {
|
|
116
|
+
switch (intent.type) {
|
|
117
|
+
case "exit":
|
|
118
|
+
return void onExit?.(0);
|
|
119
|
+
case "status":
|
|
120
|
+
return controller.setStatus(intent.message);
|
|
121
|
+
case "move": {
|
|
122
|
+
const navigableCount = isDiff ? rows.length : display.length;
|
|
123
|
+
if (intent.to === "down") setCursor((current) => Math.min(navigableCount - 1, current + 1));
|
|
124
|
+
else if (intent.to === "up") setCursor((current) => Math.max(0, current - 1));
|
|
125
|
+
else if (intent.to === "top") setCursor(0);
|
|
126
|
+
else setCursor(navigableCount - 1);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
case "inboxMove": {
|
|
130
|
+
const navigableCount = inbox?.length ?? 0;
|
|
131
|
+
setInboxCursor((current) => (intent.to === "down" ? Math.min(navigableCount - 1, current + 1) : Math.max(0, current - 1)));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
case "openSession": {
|
|
135
|
+
const selected = inbox?.[inboxCursor];
|
|
136
|
+
if (selected) controller.open(selected.id);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
case "startSpan": {
|
|
140
|
+
const block = display[cursor];
|
|
141
|
+
if (!block?.work) return;
|
|
142
|
+
const span = startSpan(cursor, displayText(block));
|
|
143
|
+
if (span) setMode({ type: "span", span });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
case "spanKey":
|
|
147
|
+
if (mode.type === "span") {
|
|
148
|
+
const span = spanKey(mode.span, intent.name, displayText(display[mode.span.displayIndex]!));
|
|
149
|
+
setMode({ type: "span", span });
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
case "openCompose": {
|
|
153
|
+
liveInput.current = "";
|
|
154
|
+
if (intent.from === "span" && mode.type === "span") {
|
|
155
|
+
setMode({
|
|
156
|
+
type: "compose",
|
|
157
|
+
kind: intent.kind,
|
|
158
|
+
displayIndex: mode.span.displayIndex,
|
|
159
|
+
start: mode.span.start,
|
|
160
|
+
end: mode.span.end,
|
|
161
|
+
text: "",
|
|
162
|
+
});
|
|
163
|
+
} else if (isDiff) {
|
|
164
|
+
const row = rows[cursor];
|
|
165
|
+
if (row) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: row.text.length, text: "" });
|
|
166
|
+
} else {
|
|
167
|
+
// a mouse drag leaves a native selection; it wins over the cursor block
|
|
168
|
+
const native = planSheetRef.current?.readSelection() ?? null;
|
|
169
|
+
if (native) {
|
|
170
|
+
setMode({ type: "compose", kind: intent.kind, ...native, text: "" });
|
|
171
|
+
} else {
|
|
172
|
+
const block = display[cursor];
|
|
173
|
+
if (block) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: displayText(block).length, text: "" });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
case "openSubmit":
|
|
179
|
+
if (!session) return;
|
|
180
|
+
liveInput.current = "";
|
|
181
|
+
// the confirm card lives in the expanded review rail; a compact or hidden
|
|
182
|
+
// panel would swallow the whole submit flow, so force the rail open (live
|
|
183
|
+
// only - the saved panel preference is left untouched)
|
|
184
|
+
setReviewMode("expanded");
|
|
185
|
+
setRailTab("review");
|
|
186
|
+
return void setMode({ type: "submit", verdict: defaultVerdict(session), summary: "" });
|
|
187
|
+
case "share":
|
|
188
|
+
return controller.share();
|
|
189
|
+
case "cut":
|
|
190
|
+
return controller.cut(cursor);
|
|
191
|
+
case "edit":
|
|
192
|
+
return runEditorHandOff();
|
|
193
|
+
case "editCard":
|
|
194
|
+
if (focusedAnnotationId) openCardEdit(focusedAnnotationId);
|
|
195
|
+
return;
|
|
196
|
+
case "nextAnnotation":
|
|
197
|
+
case "prevAnnotation": {
|
|
198
|
+
// cycle open cards only - addressed ones are out of the rail
|
|
199
|
+
const annotations = (session?.annotations ?? []).filter((annotation) => !isAddressed(annotation));
|
|
200
|
+
if (!annotations.length) return;
|
|
201
|
+
const focusedIndex = annotations.findIndex((annotation) => annotation.id === focusedAnnotationId);
|
|
202
|
+
const nextIndex =
|
|
203
|
+
focusedIndex === -1
|
|
204
|
+
? 0
|
|
205
|
+
: (focusedIndex + (intent.type === "nextAnnotation" ? 1 : -1) + annotations.length) % annotations.length;
|
|
206
|
+
return void selectCardFromDocument(annotations[nextIndex]!.id);
|
|
207
|
+
}
|
|
208
|
+
case "walkStart":
|
|
209
|
+
return controller.walkStart();
|
|
210
|
+
case "walkForward":
|
|
211
|
+
return controller.walkForward();
|
|
212
|
+
case "walkBack":
|
|
213
|
+
return controller.walkBack();
|
|
214
|
+
case "walkLeave":
|
|
215
|
+
return controller.walkLeave();
|
|
216
|
+
case "removeAnnotation":
|
|
217
|
+
if (focusedAnnotationId) {
|
|
218
|
+
controller.removeAnnotation(focusedAnnotationId);
|
|
219
|
+
setFocusedAnnotationId(undefined);
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
case "deselect":
|
|
223
|
+
planSheetRef.current?.clearSelection();
|
|
224
|
+
setFocusedAnnotationId(undefined);
|
|
225
|
+
setPulsedAnnotationId(null);
|
|
226
|
+
return;
|
|
227
|
+
case "closeOverlay":
|
|
228
|
+
return void setMode({ type: "normal" });
|
|
229
|
+
case "saveCompose": {
|
|
230
|
+
const body = liveInput.current.trim();
|
|
231
|
+
if (mode.type === "railEdit") {
|
|
232
|
+
if (session && body) controller.updateAnnotation(mode.id, body);
|
|
233
|
+
return void setMode({ type: "normal" });
|
|
234
|
+
}
|
|
235
|
+
if (mode.type !== "compose") return;
|
|
236
|
+
if (session && body) {
|
|
237
|
+
const annotationId = controller.annotate(mode.kind, mode.displayIndex, mode.start, mode.end, body);
|
|
238
|
+
if (annotationId) setFocusedAnnotationId(annotationId);
|
|
239
|
+
}
|
|
240
|
+
return void setMode({ type: "normal" });
|
|
241
|
+
}
|
|
242
|
+
case "submitVerdict":
|
|
243
|
+
if (mode.type === "submit") controller.submit(mode.verdict, liveInput.current);
|
|
244
|
+
return void setMode({ type: "normal" });
|
|
245
|
+
case "cycleVerdict": {
|
|
246
|
+
if (mode.type !== "submit") return;
|
|
247
|
+
const verdictIndex = (VERDICTS.indexOf(mode.verdict) + intent.direction + VERDICTS.length) % VERDICTS.length;
|
|
248
|
+
return void setMode({ ...mode, verdict: VERDICTS[verdictIndex]! });
|
|
249
|
+
}
|
|
250
|
+
case "finishReview":
|
|
251
|
+
return controller.finishReview();
|
|
252
|
+
case "optInAutoClose":
|
|
253
|
+
return controller.optInAutoClose();
|
|
254
|
+
case "dismissCompletion":
|
|
255
|
+
return controller.dismissCompletion();
|
|
256
|
+
case "cycleReviewPanel": {
|
|
257
|
+
const next = cycleReviewPanelMode(reviewMode);
|
|
258
|
+
setReviewMode(next);
|
|
259
|
+
return controller.saveReviewPanel({ mode: next });
|
|
260
|
+
}
|
|
261
|
+
case "resizeReviewPanel": {
|
|
262
|
+
if (reviewMode !== "expanded") return;
|
|
263
|
+
const next = resolveReviewWidth(reviewWidth + intent.direction * REVIEW_RESIZE_STEP, terminalWidth);
|
|
264
|
+
reviewWidthRef.current = next;
|
|
265
|
+
setReviewWidth(next);
|
|
266
|
+
return controller.saveReviewPanel({ width: next });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}
|
package/src/key-bindings.test.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Binding resolution and hint generation over @opentui/keymap: config keys
|
|
3
|
-
* resolve to action names per layer, and the status-line hints derive from
|
|
4
|
-
* getActiveKeys() so rebinds surface in the hint text.
|
|
5
|
-
*/
|
|
1
|
+
/** Config keys resolve to per-layer actions, and status hints derive from getActiveKeys() so rebinds surface. */
|
|
6
2
|
|
|
7
3
|
import { describe, expect, test } from "bun:test";
|
|
8
4
|
import { DEFAULT_KEYS } from "./config";
|
|
@@ -43,28 +39,40 @@ describe("grammar-layer resolution", () => {
|
|
|
43
39
|
|
|
44
40
|
describe("mode layers own their keys", () => {
|
|
45
41
|
test("span mode resolves the span verbs, not the grammar", () => {
|
|
42
|
+
// Arrange
|
|
46
43
|
const resolver = bindings({ spanMode: true });
|
|
44
|
+
|
|
45
|
+
// Assert
|
|
47
46
|
expect(resolver.resolveAction({ name: "l", shift: false })).toBe("span_grow");
|
|
48
47
|
expect(resolver.resolveAction({ name: "c", shift: false })).toBe("span_comment");
|
|
49
48
|
expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("span_cancel");
|
|
50
49
|
});
|
|
51
50
|
|
|
52
51
|
test("the compose overlay only knows save and cancel", () => {
|
|
52
|
+
// Arrange
|
|
53
53
|
const resolver = bindings({ overlay: "compose" });
|
|
54
|
+
|
|
55
|
+
// Assert
|
|
54
56
|
expect(resolver.resolveAction({ name: "return", shift: false })).toBe("save_compose");
|
|
55
57
|
expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("cancel_overlay");
|
|
56
58
|
expect(resolver.resolveAction({ name: "j", shift: false })).toBeUndefined();
|
|
57
59
|
});
|
|
58
60
|
|
|
59
61
|
test("the submit overlay adds the verdict arrows", () => {
|
|
62
|
+
// Arrange
|
|
60
63
|
const resolver = bindings({ overlay: "submit" });
|
|
64
|
+
|
|
65
|
+
// Assert
|
|
61
66
|
expect(resolver.resolveAction({ name: "left", shift: false })).toBe("cycle_verdict_left");
|
|
62
67
|
expect(resolver.resolveAction({ name: "right", shift: false })).toBe("cycle_verdict_right");
|
|
63
68
|
expect(resolver.resolveAction({ name: "return", shift: false })).toBe("submit_verdict");
|
|
64
69
|
});
|
|
65
70
|
|
|
66
71
|
test("the walk overlay owns the bracket stepping and leaves the grammar keys dead", () => {
|
|
72
|
+
// Arrange
|
|
67
73
|
const resolver = bindings({ overlay: "walk" });
|
|
74
|
+
|
|
75
|
+
// Assert
|
|
68
76
|
expect(resolver.resolveAction({ name: "]", shift: false })).toBe("walk_next");
|
|
69
77
|
expect(resolver.resolveAction({ name: "[", shift: false })).toBe("walk_prev");
|
|
70
78
|
expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("walk_leave");
|
|
@@ -111,8 +119,11 @@ describe("getActiveKeys-generated status hints", () => {
|
|
|
111
119
|
});
|
|
112
120
|
|
|
113
121
|
test("a rebound key shows its real binding in the hint", () => {
|
|
122
|
+
// Arrange
|
|
114
123
|
const resolver = new KeyBindings({ ...DEFAULT_KEYS, comment: ["m"] });
|
|
115
124
|
resolver.setContext({ overlay: "none", spanMode: false });
|
|
125
|
+
|
|
126
|
+
// Assert
|
|
116
127
|
expect(resolver.resolveAction({ name: "m", shift: false })).toBe("comment");
|
|
117
128
|
expect(resolver.resolveAction({ name: "c", shift: false })).toBeUndefined();
|
|
118
129
|
expect(resolver.statusHint("normal")).toContain("m comment");
|
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";
|
|
@@ -30,6 +26,7 @@ function key(name: string, shift = false, meta = false): { name: string; shift:
|
|
|
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" }]],
|
|
@@ -57,6 +54,7 @@ describe("compose overlay", () => {
|
|
|
57
54
|
});
|
|
58
55
|
|
|
59
56
|
describe("submit overlay", () => {
|
|
57
|
+
// Arrange
|
|
60
58
|
const keyState = state({ overlay: "submit" });
|
|
61
59
|
const table: [string, Intent[]][] = [
|
|
62
60
|
["escape", [{ type: "closeOverlay" }]],
|
|
@@ -75,6 +73,7 @@ describe("submit overlay", () => {
|
|
|
75
73
|
});
|
|
76
74
|
|
|
77
75
|
describe("completion overlay", () => {
|
|
76
|
+
// Arrange
|
|
78
77
|
const prompt = state({ overlay: "completion-prompt" });
|
|
79
78
|
const counting = state({ overlay: "completion-counting" });
|
|
80
79
|
const table: [KeyState, string, Intent[]][] = [
|
|
@@ -97,6 +96,7 @@ describe("completion overlay", () => {
|
|
|
97
96
|
});
|
|
98
97
|
|
|
99
98
|
describe("inbox mode", () => {
|
|
99
|
+
// Arrange
|
|
100
100
|
const keyState = state({ view: "inbox" });
|
|
101
101
|
const table: [string, Intent[]][] = [
|
|
102
102
|
["j", [{ type: "inboxMove", to: "down" }]],
|
|
@@ -116,7 +116,10 @@ describe("inbox mode", () => {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
test("an empty inbox swallows every non-quit key", () => {
|
|
119
|
+
// Arrange
|
|
119
120
|
const empty = state({ view: "inbox", hasInboxItems: false });
|
|
121
|
+
|
|
122
|
+
// Assert
|
|
120
123
|
for (const name of ["j", "k", "down", "up", "return", "enter"]) {
|
|
121
124
|
expect(reduceKey(empty, key(name))).toEqual([]);
|
|
122
125
|
}
|
|
@@ -124,7 +127,63 @@ describe("inbox mode", () => {
|
|
|
124
127
|
});
|
|
125
128
|
});
|
|
126
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
|
+
|
|
127
185
|
describe("plan normal mode", () => {
|
|
186
|
+
// Arrange
|
|
128
187
|
const keyState = state();
|
|
129
188
|
const table: [string, boolean, Intent[]][] = [
|
|
130
189
|
["j", false, [{ type: "move", to: "down" }]],
|
|
@@ -154,7 +213,10 @@ describe("plan normal mode", () => {
|
|
|
154
213
|
}
|
|
155
214
|
|
|
156
215
|
test("resolved sessions guard the mutating verbs", () => {
|
|
216
|
+
// Arrange
|
|
157
217
|
const resolvedState = state({ resolved: true });
|
|
218
|
+
|
|
219
|
+
// Assert
|
|
158
220
|
for (const name of ["c", "s", "x", "e"]) {
|
|
159
221
|
expect(reduceKey(resolvedState, key(name))).toEqual([{ type: "status", message: "review submitted - read-only" }]);
|
|
160
222
|
}
|
|
@@ -163,27 +225,37 @@ describe("plan normal mode", () => {
|
|
|
163
225
|
});
|
|
164
226
|
|
|
165
227
|
test("cut text cannot host a comment or a span", () => {
|
|
228
|
+
// Arrange
|
|
166
229
|
const cut = state({ cursorAnnotatable: false });
|
|
230
|
+
|
|
231
|
+
// Assert
|
|
167
232
|
expect(reduceKey(cut, key("c"))).toEqual([{ type: "status", message: "text is cut - restore it first" }]);
|
|
168
233
|
expect(reduceKey(cut, key("s"))).toEqual([{ type: "status", message: "text is cut - restore it first" }]);
|
|
169
234
|
expect(reduceKey(cut, key("v"))).toEqual([]);
|
|
170
235
|
});
|
|
171
236
|
|
|
172
237
|
test("annotation navigation without annotations reports, delete without focus is silent", () => {
|
|
238
|
+
// Arrange
|
|
173
239
|
const none = state({ annotationCount: 0, hasFocusedAnnotation: false });
|
|
240
|
+
|
|
241
|
+
// Assert
|
|
174
242
|
expect(reduceKey(none, key("n"))).toEqual([{ type: "status", message: "no annotations" }]);
|
|
175
243
|
expect(reduceKey(none, key("p"))).toEqual([{ type: "status", message: "no annotations" }]);
|
|
176
244
|
expect(reduceKey(none, key("backspace"))).toEqual([]);
|
|
177
245
|
});
|
|
178
246
|
|
|
179
247
|
test("without a selected card x cuts the block and e opens the editor", () => {
|
|
248
|
+
// Arrange
|
|
180
249
|
const unfocused = state({ hasFocusedAnnotation: false });
|
|
250
|
+
|
|
251
|
+
// Assert
|
|
181
252
|
expect(reduceKey(unfocused, key("x"))).toEqual([{ type: "cut" }]);
|
|
182
253
|
expect(reduceKey(unfocused, key("e"))).toEqual([{ type: "edit" }]);
|
|
183
254
|
});
|
|
184
255
|
});
|
|
185
256
|
|
|
186
257
|
describe("span mode", () => {
|
|
258
|
+
// Arrange
|
|
187
259
|
const keyState = state({ spanMode: true });
|
|
188
260
|
const table: [string, Intent[]][] = [
|
|
189
261
|
["l", [{ type: "spanKey", name: "l" }]],
|
|
@@ -206,6 +278,7 @@ describe("span mode", () => {
|
|
|
206
278
|
});
|
|
207
279
|
|
|
208
280
|
describe("diff mode", () => {
|
|
281
|
+
// Arrange
|
|
209
282
|
const keyState = state({ view: "diff" });
|
|
210
283
|
const table: [string, Intent[]][] = [
|
|
211
284
|
["j", [{ type: "move", to: "down" }]],
|
|
@@ -229,6 +302,7 @@ describe("diff mode", () => {
|
|
|
229
302
|
}
|
|
230
303
|
|
|
231
304
|
test("comment guards: resolved first, then non-code rows", () => {
|
|
305
|
+
// Assert
|
|
232
306
|
expect(reduceKey(state({ view: "diff", resolved: true }), key("c"))).toEqual([
|
|
233
307
|
{ type: "status", message: "review submitted - read-only" },
|
|
234
308
|
]);
|
|
@@ -240,6 +314,7 @@ describe("diff mode", () => {
|
|
|
240
314
|
|
|
241
315
|
describe("guided walk", () => {
|
|
242
316
|
test("w enters the walk in diff view; resolved and read-only sessions answer", () => {
|
|
317
|
+
// Assert
|
|
243
318
|
expect(reduceKey(state({ view: "diff" }), key("w"))).toEqual([{ type: "walkStart" }]);
|
|
244
319
|
expect(reduceKey(state({ view: "diff", resolved: true }), key("w"))).toEqual([
|
|
245
320
|
{ type: "status", message: "review submitted - read-only" },
|
|
@@ -250,6 +325,7 @@ describe("guided walk", () => {
|
|
|
250
325
|
expect(reduceKey(state(), key("w"))).toEqual([{ type: "status", message: "the guided walk is a diff-review mode" }]);
|
|
251
326
|
});
|
|
252
327
|
|
|
328
|
+
// Arrange
|
|
253
329
|
const walking = state({ view: "diff", overlay: "walk" });
|
|
254
330
|
const table: [string, Intent[]][] = [
|
|
255
331
|
["]", [{ type: "walkForward" }]],
|
|
@@ -268,7 +344,10 @@ describe("guided walk", () => {
|
|
|
268
344
|
}
|
|
269
345
|
|
|
270
346
|
test("the end card's return leaves the walk and opens the submit confirm", () => {
|
|
347
|
+
// Arrange
|
|
271
348
|
const atEnd = state({ view: "diff", overlay: "walk", walkAtEnd: true });
|
|
349
|
+
|
|
350
|
+
// Assert
|
|
272
351
|
expect(reduceKey(atEnd, key("return"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
|
|
273
352
|
expect(reduceKey(atEnd, key("enter"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
|
|
274
353
|
});
|
|
@@ -289,17 +368,24 @@ describe("review panel controls", () => {
|
|
|
289
368
|
}
|
|
290
369
|
|
|
291
370
|
test("observers may still collapse and resize the panel (it is view state, not a mutation)", () => {
|
|
371
|
+
// Arrange
|
|
292
372
|
const observer = state({ readOnly: true });
|
|
373
|
+
|
|
374
|
+
// Assert
|
|
293
375
|
expect(reduceKey(observer, key("b"))).toEqual([{ type: "cycleReviewPanel" }]);
|
|
294
376
|
expect(reduceKey(observer, key("]"))).toEqual([{ type: "resizeReviewPanel", direction: 1 }]);
|
|
295
377
|
});
|
|
296
378
|
|
|
297
379
|
test("span mode still owns b as a span verb, not a panel cycle", () => {
|
|
380
|
+
// Assert
|
|
298
381
|
expect(reduceKey(state({ spanMode: true }), key("b"))).toEqual([{ type: "spanKey", name: "b" }]);
|
|
299
382
|
});
|
|
300
383
|
|
|
301
384
|
test("the walk overlay keeps the brackets for stepping", () => {
|
|
385
|
+
// Arrange
|
|
302
386
|
const walking = state({ view: "diff", overlay: "walk" });
|
|
387
|
+
|
|
388
|
+
// Assert
|
|
303
389
|
expect(reduceKey(walking, key("]"))).toEqual([{ type: "walkForward" }]);
|
|
304
390
|
expect(reduceKey(walking, key("["))).toEqual([{ type: "walkBack" }]);
|
|
305
391
|
});
|
|
@@ -332,15 +418,21 @@ describe("read-only filter", () => {
|
|
|
332
418
|
});
|
|
333
419
|
|
|
334
420
|
test("navigation and annotation focus still work for observers", () => {
|
|
421
|
+
// Arrange
|
|
335
422
|
const keyState = state({ readOnly: true });
|
|
423
|
+
|
|
424
|
+
// Assert
|
|
336
425
|
expect(reduceKey(keyState, key("j"))).toEqual([{ type: "move", to: "down" }]);
|
|
337
426
|
expect(reduceKey(keyState, key("n"))).toEqual([{ type: "nextAnnotation" }]);
|
|
338
427
|
expect(reduceKey(keyState, key("q"))).toEqual([{ type: "exit" }]);
|
|
339
428
|
});
|
|
340
429
|
|
|
341
430
|
test("span-mode c/s are gated by key name even under a rebound keymap", () => {
|
|
431
|
+
// Arrange
|
|
342
432
|
const rebound = { ...DEFAULT_KEYS, comment: ["m"], suggest: ["t"] };
|
|
343
433
|
const keyState = state({ spanMode: true, readOnly: true, keys: rebound });
|
|
434
|
+
|
|
435
|
+
// Assert
|
|
344
436
|
expect(reduceKey(keyState, key("c"))).toEqual([{ type: "status", message: "observer - read-only" }]);
|
|
345
437
|
expect(reduceKey(keyState, key("s"))).toEqual([{ type: "status", message: "observer - read-only" }]);
|
|
346
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" }
|
|
@@ -50,6 +51,15 @@ export interface KeyState {
|
|
|
50
51
|
/** Loaded keymap (config.ts): action -> key combos. */
|
|
51
52
|
keys: Record<string, string[]>;
|
|
52
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;
|
|
53
63
|
/** Layer that owns keys before the grammar runs. */
|
|
54
64
|
overlay: "none" | "walk" | "compose" | "submit" | "completion-prompt" | "completion-counting";
|
|
55
65
|
view: "inbox" | "plan" | "diff";
|
|
@@ -66,7 +76,7 @@ export interface KeyState {
|
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
/** Verbs that write session state; an observer never reaches their handlers. */
|
|
69
|
-
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"]);
|
|
70
80
|
|
|
71
81
|
const SPAN_KEYS = ["l", "h", "w", "b", "$", "0"];
|
|
72
82
|
|
|
@@ -127,6 +137,12 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
127
137
|
const mutating = MUTATING_ACTIONS.has(action ?? "") || (state.spanMode && (name === "c" || name === "s"));
|
|
128
138
|
if (state.readOnly && mutating) return status("observer - read-only");
|
|
129
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
|
+
|
|
130
146
|
if (state.view === "inbox") return inboxGrammar(state, name);
|
|
131
147
|
// span mode owns its single-letter keys (b slides the span back) before the
|
|
132
148
|
// review-panel controls claim them
|
|
@@ -201,6 +217,8 @@ function planGrammar(state: KeyState, action: string | undefined, name: string):
|
|
|
201
217
|
// the document selects, the rail edits: with a card selected, Cut deletes
|
|
202
218
|
// the annotation and edit rewrites the card body in place
|
|
203
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");
|
|
204
222
|
return [{ type: action }];
|
|
205
223
|
}
|
|
206
224
|
return annotationCluster(state, action) ?? [];
|
|
@@ -224,6 +242,10 @@ function annotationCluster(state: KeyState, action: string | undefined): Intent[
|
|
|
224
242
|
if (state.resolved || !state.hasFocusedAnnotation) return [];
|
|
225
243
|
return [{ type: "removeAnnotation" }];
|
|
226
244
|
}
|
|
227
|
-
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
|
+
}
|
|
228
250
|
return null;
|
|
229
251
|
}
|
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
|
});
|