@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.
@@ -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
+ }
@@ -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");
@@ -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") return state.resolved ? [] : [{ type: "openSubmit" }];
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
  });