@cueloop/client 0.1.0-alpha.32 → 0.1.0-alpha.34

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.
Files changed (101) hide show
  1. package/package.json +27 -26
  2. package/src/App.autoclose.test.tsx +7 -4
  3. package/src/App.diff.test.tsx +131 -5
  4. package/src/App.export.test.tsx +23 -5
  5. package/src/App.plan-review.test.tsx +133 -32
  6. package/src/App.readonly.test.tsx +35 -13
  7. package/src/App.rendering.test.tsx +14 -4
  8. package/src/App.review-panel.test.tsx +27 -15
  9. package/src/App.submit-confirm.test.tsx +14 -13
  10. package/src/App.test.tsx +50 -13
  11. package/src/App.toast.test.tsx +75 -0
  12. package/src/App.tsx +505 -101
  13. package/src/App.walk.test.tsx +31 -11
  14. package/src/attribution.ts +8 -2
  15. package/src/clipboard.ts +5 -1
  16. package/src/components/AnnotationCard.stories.tsx +29 -10
  17. package/src/components/AnnotationCard.tsx +57 -37
  18. package/src/components/Breadcrumb.tsx +7 -1
  19. package/src/components/CodeBlock.stories.tsx +19 -2
  20. package/src/components/CodeBlock.tsx +24 -9
  21. package/src/components/CompletionOverlay.tsx +13 -3
  22. package/src/components/ConfirmCard.stories.tsx +22 -3
  23. package/src/components/ConfirmCard.tsx +23 -5
  24. package/src/components/ConfirmDialog.tsx +10 -2
  25. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  26. package/src/components/DiffSheet.stories.tsx +35 -1
  27. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  28. package/src/components/DiffSheet.tsx +213 -101
  29. package/src/components/InboxList.stories.tsx +16 -3
  30. package/src/components/InboxList.tsx +17 -10
  31. package/src/components/KeybindsDialog.stories.tsx +31 -0
  32. package/src/components/KeybindsDialog.tsx +58 -0
  33. package/src/components/MarkerPopover.stories.tsx +58 -0
  34. package/src/components/MarkerPopover.tsx +102 -0
  35. package/src/components/MenuBar.stories.tsx +37 -0
  36. package/src/components/MenuBar.tsx +74 -0
  37. package/src/components/PlanSheet.stories.tsx +35 -3
  38. package/src/components/PlanSheet.tsx +160 -100
  39. package/src/components/PromptDialog.tsx +19 -3
  40. package/src/components/ReviewPanel.stories.tsx +17 -19
  41. package/src/components/ReviewPanel.tsx +17 -32
  42. package/src/components/ReviewRail.stories.tsx +159 -47
  43. package/src/components/ReviewRail.tsx +206 -62
  44. package/src/components/SettingsDialog.stories.tsx +51 -3
  45. package/src/components/SettingsDialog.tsx +11 -5
  46. package/src/components/SettingsRows.stories.tsx +1 -1
  47. package/src/components/SettingsRows.tsx +19 -3
  48. package/src/components/Toast.stories.tsx +12 -0
  49. package/src/components/Toast.tsx +44 -0
  50. package/src/components/WalkWizard.stories.tsx +13 -2
  51. package/src/components/WalkWizard.tsx +9 -3
  52. package/src/components/__snapshots__/stories.test.tsx.snap +853 -623
  53. package/src/components/diff-sheet-layout.test.ts +127 -0
  54. package/src/components/diff-sheet-layout.ts +167 -0
  55. package/src/components/primitives/Button.stories.tsx +2 -2
  56. package/src/components/primitives/Button.tsx +5 -1
  57. package/src/components/primitives/Dialog.tsx +12 -1
  58. package/src/components/primitives/Tabs.tsx +40 -39
  59. package/src/components/stories-app.tsx +34 -6
  60. package/src/components/story-fixtures.ts +11 -3
  61. package/src/components/story.ts +3 -1
  62. package/src/components/syntax-highlight.ts +47 -12
  63. package/src/components/theme-context.stories.tsx +6 -1
  64. package/src/components/theme-context.tsx +7 -1
  65. package/src/config.test.ts +178 -2
  66. package/src/config.ts +121 -23
  67. package/src/diff-hunk-curate.test.ts +315 -0
  68. package/src/diff-hunk-curate.ts +233 -0
  69. package/src/diff-intraline.test.ts +164 -0
  70. package/src/diff-intraline.ts +132 -0
  71. package/src/diff-syntax.test.ts +96 -0
  72. package/src/diff-syntax.ts +154 -0
  73. package/src/editor.test.ts +13 -4
  74. package/src/editor.ts +31 -5
  75. package/src/herdr.ts +8 -2
  76. package/src/index.ts +9 -1
  77. package/src/integrations.ts +3 -1
  78. package/src/intent-dispatch.test.ts +193 -9
  79. package/src/intent-dispatch.ts +151 -18
  80. package/src/key-bindings.test.ts +19 -9
  81. package/src/key-bindings.ts +77 -16
  82. package/src/keymap.test.ts +171 -26
  83. package/src/keymap.ts +83 -16
  84. package/src/review-panel.test.ts +3 -1
  85. package/src/review-panel.ts +9 -2
  86. package/src/serve.test.ts +13 -5
  87. package/src/session-controller.curate.test.ts +253 -0
  88. package/src/session-controller.share.test.ts +77 -16
  89. package/src/session-controller.ts +319 -26
  90. package/src/share.ts +41 -8
  91. package/src/test-support.ts +97 -23
  92. package/src/theme-presets.ts +168 -0
  93. package/src/theme.ts +1 -3
  94. package/src/version.ts +5 -0
  95. package/src/view-diff.ts +15 -4
  96. package/src/view-plan.test.ts +40 -6
  97. package/src/view-plan.ts +71 -28
  98. package/src/walk.test.ts +7 -2
  99. package/src/walk.ts +7 -2
  100. package/src/components/ComposeBar.stories.tsx +0 -18
  101. package/src/components/ComposeBar.tsx +0 -54
@@ -4,7 +4,9 @@ import { describe, expect, test } from "bun:test";
4
4
  import { DEFAULT_KEYS } from "./config";
5
5
  import { KeyBindings } from "./key-bindings";
6
6
 
7
- function bindings(context: { overlay?: "none" | "walk" | "compose" | "submit"; spanMode?: boolean } = {}): KeyBindings {
7
+ function bindings(
8
+ context: { overlay?: "none" | "walk" | "compose" | "submit"; spanMode?: boolean } = {},
9
+ ): KeyBindings {
8
10
  const resolver = new KeyBindings(DEFAULT_KEYS);
9
11
  resolver.setContext({ overlay: context.overlay ?? "none", spanMode: context.spanMode ?? false });
10
12
  return resolver;
@@ -19,7 +21,7 @@ describe("grammar-layer resolution", () => {
19
21
  ["g", true, "bottom"],
20
22
  ["v", false, "span"],
21
23
  ["c", false, "comment"],
22
- ["s", false, "suggest"],
24
+ ["s", false, undefined],
23
25
  ["x", false, "cut"],
24
26
  ["e", false, "edit"],
25
27
  ["n", false, "next_annotation"],
@@ -86,26 +88,32 @@ describe("mode layers own their keys", () => {
86
88
  describe("getActiveKeys-generated status hints", () => {
87
89
  test("normal mode hint matches the locked string", () => {
88
90
  expect(bindings().statusHint("normal")).toBe(
89
- "j/k move · v span · drag selects · c comment · s suggest · x cut · e edit · n/p annotations · ⏎ submit · q quit",
91
+ "j/k move · v span · drag selects · c comment · x cut · e edit · n/p annotations · enter submit · q quit",
90
92
  );
91
93
  });
92
94
 
93
95
  test("card-selected hint keeps the locked Cut label", () => {
94
- expect(bindings().statusHint("card")).toBe("card · e edit · x Cut · n/p cards · esc deselect · ⏎ submit");
96
+ expect(bindings().statusHint("card")).toBe(
97
+ "card · e edit · x Cut · n/p cards · esc deselect · enter submit",
98
+ );
95
99
  });
96
100
 
97
101
  test("span hint", () => {
98
102
  expect(bindings({ spanMode: true }).statusHint("span")).toBe(
99
- "span · l/h grow/shrink · w/b slide · $ end · c comment · s suggest · esc",
103
+ "span · l/h grow/shrink · w/b slide · $ end · c comment · esc",
100
104
  );
101
105
  });
102
106
 
103
107
  test("compose hint", () => {
104
- expect(bindings({ overlay: "compose" }).statusHint("compose")).toBe("typing · ⏎ save · esc cancel");
108
+ expect(bindings({ overlay: "compose" }).statusHint("compose")).toBe(
109
+ "typing · enter save · esc cancel",
110
+ );
105
111
  });
106
112
 
107
113
  test("submit hint", () => {
108
- expect(bindings({ overlay: "submit" }).statusHint("submit")).toBe("verdict ←/→ · ⏎ submit · esc cancel");
114
+ expect(bindings({ overlay: "submit" }).statusHint("submit")).toBe(
115
+ "verdict ←/→ · enter submit · esc cancel",
116
+ );
109
117
  });
110
118
 
111
119
  test("walk hint", () => {
@@ -115,7 +123,9 @@ describe("getActiveKeys-generated status hints", () => {
115
123
  });
116
124
 
117
125
  test("read-only hint", () => {
118
- expect(bindings().statusHint("read-only")).toBe("observer - read-only · j/k move · n/p annotations · q quit");
126
+ expect(bindings().statusHint("read-only")).toBe(
127
+ "observer - read-only · j/k move · n/p annotations · q quit",
128
+ );
119
129
  });
120
130
 
121
131
  test("collaborator hint annotates but shows no plan edit or submit", () => {
@@ -123,7 +133,7 @@ describe("getActiveKeys-generated status hints", () => {
123
133
  const hint = bindings().statusHint("collaborator");
124
134
 
125
135
  // Assert
126
- expect(hint).toBe("j/k move · v span · drag selects · c comment · s suggest · n/p annotations · q quit");
136
+ expect(hint).toBe("j/k move · v span · drag selects · c comment · n/p annotations · q quit");
127
137
  expect(hint).not.toContain("cut");
128
138
  expect(hint).not.toContain("edit");
129
139
  expect(hint).not.toContain("submit");
@@ -24,11 +24,26 @@ export interface KeyLayerContext {
24
24
  }
25
25
 
26
26
  /** Hint templates keyed by the same view states the status line shows. */
27
- export type HintMode = "normal" | "collaborator" | "card" | "span" | "compose" | "submit" | "walk" | "read-only";
27
+ export type HintMode =
28
+ | "normal"
29
+ | "collaborator"
30
+ | "card"
31
+ | "span"
32
+ | "compose"
33
+ | "submit"
34
+ | "walk"
35
+ | "read-only";
28
36
 
29
- type HintEntry =
30
- | { text: string }
31
- | { commands: string[]; label: string; labelFirst?: boolean };
37
+ export interface CheatsheetEntry {
38
+ keys: string;
39
+ label: string;
40
+ }
41
+ export interface CheatsheetSection {
42
+ title: string;
43
+ entries: CheatsheetEntry[];
44
+ }
45
+
46
+ type HintEntry = { text: string } | { commands: string[]; label: string; labelFirst?: boolean };
32
47
 
33
48
  const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
34
49
  normal: [
@@ -36,7 +51,6 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
36
51
  { commands: ["span"], label: "span" },
37
52
  { text: "drag selects" },
38
53
  { commands: ["comment"], label: "comment" },
39
- { commands: ["suggest"], label: "suggest" },
40
54
  { commands: ["cut"], label: "cut" },
41
55
  { commands: ["edit"], label: "edit" },
42
56
  { commands: ["next_annotation", "prev_annotation"], label: "annotations" },
@@ -49,7 +63,6 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
49
63
  { commands: ["span"], label: "span" },
50
64
  { text: "drag selects" },
51
65
  { commands: ["comment"], label: "comment" },
52
- { commands: ["suggest"], label: "suggest" },
53
66
  { commands: ["next_annotation", "prev_annotation"], label: "annotations" },
54
67
  { commands: ["quit"], label: "quit" },
55
68
  ],
@@ -67,7 +80,6 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
67
80
  { commands: ["span_slide_forward", "span_slide_back"], label: "slide" },
68
81
  { commands: ["span_to_end"], label: "end" },
69
82
  { commands: ["span_comment"], label: "comment" },
70
- { commands: ["span_suggest"], label: "suggest" },
71
83
  { commands: ["span_cancel"], label: "" },
72
84
  ],
73
85
  compose: [
@@ -97,8 +109,8 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
97
109
 
98
110
  /** Status-line glyphs for named keys. */
99
111
  const KEY_GLYPHS: Record<string, string> = {
100
- return: "⏎",
101
- enter: "⏎",
112
+ return: "enter",
113
+ enter: "enter",
102
114
  escape: "esc",
103
115
  left: "←",
104
116
  right: "→",
@@ -173,7 +185,10 @@ class HeadlessKeymapHost implements KeymapHost<object, KeymapEvent> {
173
185
  function normalizedEvent(key: ResolvableKey): { name: string; shift: boolean } {
174
186
  if (key.name.length === 1) {
175
187
  if (/[a-zA-Z]/.test(key.name)) {
176
- return { name: key.name.toLowerCase(), shift: key.shift || key.name !== key.name.toLowerCase() };
188
+ return {
189
+ name: key.name.toLowerCase(),
190
+ shift: key.shift || key.name !== key.name.toLowerCase(),
191
+ };
177
192
  }
178
193
  return { name: key.name, shift: false };
179
194
  }
@@ -195,7 +210,6 @@ const SPAN_COMMANDS: [key: string, command: string][] = [
195
210
  ["$", "span_to_end"],
196
211
  ["0", "span_to_start"],
197
212
  ["c", "span_comment"],
198
- ["s", "span_suggest"],
199
213
  ["escape", "span_cancel"],
200
214
  ];
201
215
 
@@ -272,7 +286,13 @@ export class KeyBindings {
272
286
  { key: "enter", cmd: "walk_submit" },
273
287
  { key: "q", cmd: "quit" },
274
288
  ],
275
- commands: [record("walk_next"), record("walk_prev"), record("walk_leave"), record("walk_submit"), record("quit")],
289
+ commands: [
290
+ record("walk_next"),
291
+ record("walk_prev"),
292
+ record("walk_leave"),
293
+ record("walk_submit"),
294
+ record("quit"),
295
+ ],
276
296
  });
277
297
  this.keymap.registerLayer({
278
298
  priority: 20,
@@ -303,7 +323,9 @@ export class KeyBindings {
303
323
  });
304
324
  this.keymap.registerLayer({
305
325
  priority: 20,
306
- when: () => this.context.overlay === "completion-prompt" || this.context.overlay === "completion-counting",
326
+ when: () =>
327
+ this.context.overlay === "completion-prompt" ||
328
+ this.context.overlay === "completion-counting",
307
329
  bindings: [
308
330
  { key: "return", cmd: "finish_review" },
309
331
  { key: "enter", cmd: "finish_review" },
@@ -311,7 +333,11 @@ export class KeyBindings {
311
333
  { key: "a", cmd: "opt_in_auto_close" },
312
334
  { key: "escape", cmd: "dismiss_completion" },
313
335
  ],
314
- commands: [record("finish_review"), record("opt_in_auto_close"), record("dismiss_completion")],
336
+ commands: [
337
+ record("finish_review"),
338
+ record("opt_in_auto_close"),
339
+ record("dismiss_completion"),
340
+ ],
315
341
  });
316
342
  }
317
343
 
@@ -335,7 +361,9 @@ export class KeyBindings {
335
361
  private keyDisplayFor(command: string): string | null {
336
362
  for (const activeKey of this.keymap.getActiveKeys({ includeBindings: true })) {
337
363
  const bindingCommand =
338
- typeof activeKey.command === "string" ? activeKey.command : activeKey.bindings?.[0]?.command;
364
+ typeof activeKey.command === "string"
365
+ ? activeKey.command
366
+ : activeKey.bindings?.[0]?.command;
339
367
  if (bindingCommand !== command) continue;
340
368
  return KEY_GLYPHS[activeKey.stroke.name] ?? activeKey.display;
341
369
  }
@@ -356,8 +384,41 @@ export class KeyBindings {
356
384
  if (!displays.length) continue;
357
385
  const keyPart = displays.join("/");
358
386
  if (!entry.label) fragments.push(keyPart);
359
- else fragments.push(entry.labelFirst ? `${entry.label} ${keyPart}` : `${keyPart} ${entry.label}`);
387
+ else
388
+ fragments.push(
389
+ entry.labelFirst ? `${entry.label} ${keyPart}` : `${keyPart} ${entry.label}`,
390
+ );
360
391
  }
361
392
  return fragments.join(" · ");
362
393
  }
394
+
395
+ /**
396
+ * The full keybinding cheatsheet, grouped by mode. Each section sets the
397
+ * layer context so getActiveKeys resolves that mode's real bindings, then
398
+ * restores it - a rebound key shows its live binding here too.
399
+ */
400
+ cheatsheet(): CheatsheetSection[] {
401
+ const saved = this.context;
402
+ const build = (context: KeyLayerContext, mode: HintMode, title: string): CheatsheetSection => {
403
+ this.context = context;
404
+ const entries: CheatsheetEntry[] = [];
405
+ for (const entry of HINT_TEMPLATES[mode]) {
406
+ if ("text" in entry || !entry.label) continue;
407
+ const keys = entry.commands
408
+ .map((command) => this.keyDisplayFor(command))
409
+ .filter((display): display is string => display !== null)
410
+ .join(" / ");
411
+ if (keys) entries.push({ keys, label: entry.label });
412
+ }
413
+ return { title, entries };
414
+ };
415
+ const sections = [
416
+ build({ overlay: "none", spanMode: false }, "normal", "Review"),
417
+ build({ overlay: "none", spanMode: true }, "span", "Selection"),
418
+ build({ overlay: "submit", spanMode: false }, "submit", "Submit"),
419
+ build({ overlay: "walk", spanMode: false }, "walk", "Walk"),
420
+ ];
421
+ this.context = saved;
422
+ return sections.filter((section) => section.entries.length > 0);
423
+ }
363
424
  }
@@ -21,7 +21,11 @@ function state(patch: Partial<KeyState> = {}): KeyState {
21
21
  };
22
22
  }
23
23
 
24
- function key(name: string, shift = false, meta = false): { name: string; shift: boolean; meta: boolean } {
24
+ function key(
25
+ name: string,
26
+ shift = false,
27
+ meta = false,
28
+ ): { name: string; shift: boolean; meta: boolean } {
25
29
  return { name, shift, meta };
26
30
  }
27
31
 
@@ -133,7 +137,9 @@ describe("collaborator (share) capabilities", () => {
133
137
  const collab = state({ canEditPlan: false, canSubmitVerdict: false });
134
138
 
135
139
  // Act / Assert
136
- expect(reduceKey(collab, key("c"), "comment")).toEqual([{ type: "openCompose", kind: "comment", from: "cursor" }]);
140
+ expect(reduceKey(collab, key("c"), "comment")).toEqual([
141
+ { type: "openCompose", kind: "comment", from: "cursor" },
142
+ ]);
137
143
  });
138
144
 
139
145
  test("can edit their own card: edit with a focused annotation rewrites it", () => {
@@ -158,7 +164,9 @@ describe("collaborator (share) capabilities", () => {
158
164
  const collab = state({ canSubmitVerdict: false });
159
165
 
160
166
  // Act / Assert
161
- expect(reduceKey(collab, key("x"), "submit")).toEqual([{ type: "status", message: "shared view - your notes save as you go; q to leave" }]);
167
+ expect(reduceKey(collab, key("x"), "submit")).toEqual([
168
+ { type: "status", message: "shared view - your notes save as you go; q to leave" },
169
+ ]);
162
170
  });
163
171
 
164
172
  test("cannot re-share: only the owner shares", () => {
@@ -166,7 +174,9 @@ describe("collaborator (share) capabilities", () => {
166
174
  const collab = state({ canShare: false });
167
175
 
168
176
  // Act / Assert
169
- expect(reduceKey(collab, key("S", true), "share")).toEqual([{ type: "status", message: "only the plan owner can share" }]);
177
+ expect(reduceKey(collab, key("S", true), "share")).toEqual([
178
+ { type: "status", message: "only the plan owner can share" },
179
+ ]);
170
180
  });
171
181
  });
172
182
 
@@ -178,7 +188,9 @@ describe("share", () => {
178
188
 
179
189
  test("an observer cannot share", () => {
180
190
  // Act / Assert
181
- expect(reduceKey(state({ readOnly: true }), key("S", true), "share")).toEqual([{ type: "status", message: "observer - read-only" }]);
191
+ expect(reduceKey(state({ readOnly: true }), key("S", true), "share")).toEqual([
192
+ { type: "status", message: "observer - read-only" },
193
+ ]);
182
194
  });
183
195
  });
184
196
 
@@ -194,7 +206,6 @@ describe("plan normal mode", () => {
194
206
  ["g", true, [{ type: "move", to: "bottom" }]],
195
207
  ["v", false, [{ type: "startSpan" }]],
196
208
  ["c", false, [{ type: "openCompose", kind: "comment", from: "cursor" }]],
197
- ["s", false, [{ type: "openCompose", kind: "suggestion", from: "cursor" }]],
198
209
  // the default state has a selected card, so Cut and edit act on the card
199
210
  ["x", false, [{ type: "removeAnnotation" }]],
200
211
  ["e", false, [{ type: "editCard" }]],
@@ -217,20 +228,35 @@ describe("plan normal mode", () => {
217
228
  const resolvedState = state({ resolved: true });
218
229
 
219
230
  // Assert
220
- for (const name of ["c", "s", "x", "e"]) {
221
- expect(reduceKey(resolvedState, key(name))).toEqual([{ type: "status", message: "review submitted - read-only" }]);
231
+ for (const name of ["c", "x", "e"]) {
232
+ expect(reduceKey(resolvedState, key(name))).toEqual([
233
+ { type: "status", message: "review submitted - read-only" },
234
+ ]);
222
235
  }
223
236
  expect(reduceKey(resolvedState, key("return"))).toEqual([]);
224
237
  expect(reduceKey(resolvedState, key("backspace"))).toEqual([]);
225
238
  });
226
239
 
240
+ test("u restores a rail removal (a cut block); a share viewer's is silent", () => {
241
+ // Assert - the undo intent leaves the target to the dispatcher
242
+ expect(reduceKey(state(), key("u"))).toEqual([{ type: "restoreCuration" }]);
243
+ expect(reduceKey(state({ resolved: true }), key("u"))).toEqual([
244
+ { type: "status", message: "review submitted - read-only" },
245
+ ]);
246
+ expect(reduceKey(state({ canEditPlan: false }), key("u"))).toEqual([]);
247
+ expect(reduceKey(state({ readOnly: true }), key("u"))).toEqual([
248
+ { type: "status", message: "observer - read-only" },
249
+ ]);
250
+ });
251
+
227
252
  test("cut text cannot host a comment or a span", () => {
228
253
  // Arrange
229
254
  const cut = state({ cursorAnnotatable: false });
230
255
 
231
256
  // Assert
232
- expect(reduceKey(cut, key("c"))).toEqual([{ type: "status", message: "text is cut - restore it first" }]);
233
- expect(reduceKey(cut, key("s"))).toEqual([{ type: "status", message: "text is cut - restore it first" }]);
257
+ expect(reduceKey(cut, key("c"))).toEqual([
258
+ { type: "status", message: "text is cut - restore it first" },
259
+ ]);
234
260
  expect(reduceKey(cut, key("v"))).toEqual([]);
235
261
  });
236
262
 
@@ -265,7 +291,8 @@ describe("span mode", () => {
265
291
  ["$", [{ type: "spanKey", name: "$" }]],
266
292
  ["0", [{ type: "spanKey", name: "0" }]],
267
293
  ["c", [{ type: "openCompose", kind: "comment", from: "span" }]],
268
- ["s", [{ type: "openCompose", kind: "suggestion", from: "span" }]],
294
+ ["x", [{ type: "spanCut" }]],
295
+ ["a", [{ type: "openSpanActions" }]],
269
296
  ["escape", [{ type: "closeOverlay" }]],
270
297
  ["q", [{ type: "exit" }]],
271
298
  ["j", []],
@@ -275,6 +302,73 @@ describe("span mode", () => {
275
302
  expect(reduceKey(keyState, key(name))).toEqual(expected);
276
303
  });
277
304
  }
305
+
306
+ test("an observer's span-mode mutations answer read-only", () => {
307
+ // Arrange
308
+ const observer = state({ spanMode: true, readOnly: true });
309
+
310
+ // Assert
311
+ expect(reduceKey(observer, key("x"))).toEqual([
312
+ { type: "status", message: "observer - read-only" },
313
+ ]);
314
+ expect(reduceKey(observer, key("a"))).toEqual([
315
+ { type: "status", message: "observer - read-only" },
316
+ ]);
317
+ expect(reduceKey(observer, key("c"))).toEqual([
318
+ { type: "status", message: "observer - read-only" },
319
+ ]);
320
+ });
321
+
322
+ test("span-mode cut is owner-only, like the normal plan cut", () => {
323
+ // Arrange
324
+ const collaborator = state({ spanMode: true, canEditPlan: false });
325
+
326
+ // Assert
327
+ expect(reduceKey(collaborator, key("x"))).toEqual([]);
328
+ });
329
+
330
+ test("all span-mode mutations are blocked once the review is resolved", () => {
331
+ // Arrange
332
+ const resolved = state({ spanMode: true, resolved: true });
333
+ const readOnlyStatus: Intent[] = [{ type: "status", message: "review submitted - read-only" }];
334
+
335
+ // Assert - comment, cut, and quick-actions all answer read-only
336
+ expect(reduceKey(resolved, key("x"))).toEqual(readOnlyStatus);
337
+ expect(reduceKey(resolved, key("c"))).toEqual(readOnlyStatus);
338
+ expect(reduceKey(resolved, key("a"))).toEqual(readOnlyStatus);
339
+ });
340
+
341
+ test("span-mode cut gates for an observer even when cut is rebound off x", () => {
342
+ // Arrange - the span sub-grammar hardwires x, so the read-only gate must catch it by name
343
+ const rebound = { ...DEFAULT_KEYS, cut: ["d"] };
344
+ const observer = state({ spanMode: true, readOnly: true, keys: rebound });
345
+
346
+ // Assert
347
+ expect(reduceKey(observer, key("x"))).toEqual([
348
+ { type: "status", message: "observer - read-only" },
349
+ ]);
350
+ });
351
+ });
352
+
353
+ describe("span actions overlay", () => {
354
+ // Arrange
355
+ const keyState = state({ overlay: "spanActions" });
356
+ const table: [string, Intent[]][] = [
357
+ ["j", [{ type: "moveSpanAction", direction: 1 }]],
358
+ ["down", [{ type: "moveSpanAction", direction: 1 }]],
359
+ ["k", [{ type: "moveSpanAction", direction: -1 }]],
360
+ ["up", [{ type: "moveSpanAction", direction: -1 }]],
361
+ ["return", [{ type: "pickSpanAction" }]],
362
+ ["enter", [{ type: "pickSpanAction" }]],
363
+ ["escape", [{ type: "closeSpanActions" }]],
364
+ ["c", []],
365
+ ["q", []],
366
+ ];
367
+ for (const [name, expected] of table) {
368
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
369
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
370
+ });
371
+ }
278
372
  });
279
373
 
280
374
  describe("diff mode", () => {
@@ -290,9 +384,8 @@ describe("diff mode", () => {
290
384
  ["backspace", [{ type: "removeAnnotation" }]],
291
385
  ["return", [{ type: "openSubmit" }]],
292
386
  ["v", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
293
- ["x", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
387
+ ["x", [{ type: "rejectChange" }]],
294
388
  ["e", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
295
- ["s", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
296
389
  ["q", [{ type: "exit" }]],
297
390
  ];
298
391
  for (const [name, expected] of table) {
@@ -301,6 +394,40 @@ describe("diff mode", () => {
301
394
  });
302
395
  }
303
396
 
397
+ test("shift+x rejects the whole hunk under the cursor", () => {
398
+ // Assert
399
+ expect(reduceKey(keyState, key("x", true))).toEqual([{ type: "rejectHunk" }]);
400
+ });
401
+
402
+ test("curation is owner-only and locked once resolved", () => {
403
+ // Assert - a resolved diff answers before curating
404
+ expect(reduceKey(state({ view: "diff", resolved: true }), key("x"))).toEqual([
405
+ { type: "status", message: "review submitted - read-only" },
406
+ ]);
407
+ // a collaborator (canEditPlan false) cannot curate hunks
408
+ expect(reduceKey(state({ view: "diff", canEditPlan: false }), key("x"))).toEqual([
409
+ { type: "status", message: "only the diff owner can curate hunks" },
410
+ ]);
411
+ // an observer's mutating attempt is stopped by the read-only gate
412
+ expect(reduceKey(state({ view: "diff", readOnly: true }), key("x"))).toEqual([
413
+ { type: "status", message: "observer - read-only" },
414
+ ]);
415
+ });
416
+
417
+ test("u restores a curated-out item, owner-only and locked once resolved", () => {
418
+ // Assert - the undo intent leaves the target to the dispatcher
419
+ expect(reduceKey(keyState, key("u"))).toEqual([{ type: "restoreCuration" }]);
420
+ expect(reduceKey(state({ view: "diff", resolved: true }), key("u"))).toEqual([
421
+ { type: "status", message: "review submitted - read-only" },
422
+ ]);
423
+ expect(reduceKey(state({ view: "diff", canEditPlan: false }), key("u"))).toEqual([
424
+ { type: "status", message: "only the diff owner can curate hunks" },
425
+ ]);
426
+ expect(reduceKey(state({ view: "diff", readOnly: true }), key("u"))).toEqual([
427
+ { type: "status", message: "observer - read-only" },
428
+ ]);
429
+ });
430
+
304
431
  test("comment guards: resolved first, then non-code rows", () => {
305
432
  // Assert
306
433
  expect(reduceKey(state({ view: "diff", resolved: true }), key("c"))).toEqual([
@@ -322,7 +449,9 @@ describe("guided walk", () => {
322
449
  expect(reduceKey(state({ view: "diff", readOnly: true }), key("w"))).toEqual([
323
450
  { type: "status", message: "observer - read-only" },
324
451
  ]);
325
- expect(reduceKey(state(), key("w"))).toEqual([{ type: "status", message: "the guided walk is a diff-review mode" }]);
452
+ expect(reduceKey(state(), key("w"))).toEqual([
453
+ { type: "status", message: "the guided walk is a diff-review mode" },
454
+ ]);
326
455
  });
327
456
 
328
457
  // Arrange
@@ -348,7 +477,10 @@ describe("guided walk", () => {
348
477
  const atEnd = state({ view: "diff", overlay: "walk", walkAtEnd: true });
349
478
 
350
479
  // Assert
351
- expect(reduceKey(atEnd, key("return"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
480
+ expect(reduceKey(atEnd, key("return"))).toEqual([
481
+ { type: "walkLeave" },
482
+ { type: "openSubmit" },
483
+ ]);
352
484
  expect(reduceKey(atEnd, key("enter"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
353
485
  });
354
486
  });
@@ -378,7 +510,9 @@ describe("review panel controls", () => {
378
510
 
379
511
  test("span mode still owns b as a span verb, not a panel cycle", () => {
380
512
  // Assert
381
- expect(reduceKey(state({ spanMode: true }), key("b"))).toEqual([{ type: "spanKey", name: "b" }]);
513
+ expect(reduceKey(state({ spanMode: true }), key("b"))).toEqual([
514
+ { type: "spanKey", name: "b" },
515
+ ]);
382
516
  });
383
517
 
384
518
  test("the walk overlay keeps the brackets for stepping", () => {
@@ -411,8 +545,10 @@ describe("read-only filter", () => {
411
545
  test("every mutating key answers observer - read-only, in plan and diff", () => {
412
546
  for (const view of ["plan", "diff"] as const) {
413
547
  const keyState = state({ view, readOnly: true });
414
- for (const name of ["c", "s", "x", "e", "backspace", "return", "enter"]) {
415
- expect(reduceKey(keyState, key(name))).toEqual([{ type: "status", message: "observer - read-only" }]);
548
+ for (const name of ["c", "x", "e", "backspace", "return", "enter"]) {
549
+ expect(reduceKey(keyState, key(name))).toEqual([
550
+ { type: "status", message: "observer - read-only" },
551
+ ]);
416
552
  }
417
553
  }
418
554
  });
@@ -427,21 +563,24 @@ describe("read-only filter", () => {
427
563
  expect(reduceKey(keyState, key("q"))).toEqual([{ type: "exit" }]);
428
564
  });
429
565
 
430
- test("span-mode c/s are gated by key name even under a rebound keymap", () => {
566
+ test("span-mode c is gated by key name even under a rebound keymap", () => {
431
567
  // Arrange
432
- const rebound = { ...DEFAULT_KEYS, comment: ["m"], suggest: ["t"] };
568
+ const rebound = { ...DEFAULT_KEYS, comment: ["m"] };
433
569
  const keyState = state({ spanMode: true, readOnly: true, keys: rebound });
434
570
 
435
571
  // Assert
436
- expect(reduceKey(keyState, key("c"))).toEqual([{ type: "status", message: "observer - read-only" }]);
437
- expect(reduceKey(keyState, key("s"))).toEqual([{ type: "status", message: "observer - read-only" }]);
572
+ expect(reduceKey(keyState, key("c"))).toEqual([
573
+ { type: "status", message: "observer - read-only" },
574
+ ]);
438
575
  });
439
576
  });
440
577
 
441
578
  describe("inbox delete", () => {
442
579
  test("d on a session requests delete", () => {
443
580
  // Arrange / Act / Assert
444
- expect(reduceKey(state({ view: "inbox" }), key("d"))).toEqual([{ type: "requestDeleteSession" }]);
581
+ expect(reduceKey(state({ view: "inbox" }), key("d"))).toEqual([
582
+ { type: "requestDeleteSession" },
583
+ ]);
445
584
  });
446
585
  });
447
586
 
@@ -465,13 +604,19 @@ describe("confirm overlay", () => {
465
604
  describe("rename grammar", () => {
466
605
  test("r on a focused note opens rename", () => {
467
606
  // Arrange / Act / Assert
468
- expect(reduceKey(state({ hasFocusedAnnotation: true }), key("r"))).toEqual([{ type: "openRename" }]);
607
+ expect(reduceKey(state({ hasFocusedAnnotation: true }), key("r"))).toEqual([
608
+ { type: "openRename" },
609
+ ]);
469
610
  });
470
611
 
471
612
  test("prompt overlay routes ⏎ to confirm and esc to cancel", () => {
472
613
  // Arrange / Act / Assert
473
- expect(reduceKey(state({ overlay: "prompt" }), key("return"))).toEqual([{ type: "confirmDialog" }]);
474
- expect(reduceKey(state({ overlay: "prompt" }), key("escape"))).toEqual([{ type: "closeOverlay" }]);
614
+ expect(reduceKey(state({ overlay: "prompt" }), key("return"))).toEqual([
615
+ { type: "confirmDialog" },
616
+ ]);
617
+ expect(reduceKey(state({ overlay: "prompt" }), key("escape"))).toEqual([
618
+ { type: "closeOverlay" },
619
+ ]);
475
620
  expect(reduceKey(state({ overlay: "prompt" }), key("a"))).toEqual([]);
476
621
  });
477
622
  });