@cueloop/client 0.1.0-alpha.33 → 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 (94) hide show
  1. package/package.json +27 -26
  2. package/src/App.autoclose.test.tsx +7 -4
  3. package/src/App.diff.test.tsx +130 -4
  4. package/src/App.export.test.tsx +22 -4
  5. package/src/App.plan-review.test.tsx +127 -27
  6. package/src/App.readonly.test.tsx +31 -8
  7. package/src/App.rendering.test.tsx +12 -3
  8. package/src/App.review-panel.test.tsx +11 -3
  9. package/src/App.submit-confirm.test.tsx +12 -7
  10. package/src/App.test.tsx +46 -9
  11. package/src/App.toast.test.tsx +10 -3
  12. package/src/App.tsx +400 -100
  13. package/src/App.walk.test.tsx +30 -10
  14. package/src/attribution.ts +8 -2
  15. package/src/clipboard.ts +5 -1
  16. package/src/components/AnnotationCard.stories.tsx +28 -9
  17. package/src/components/AnnotationCard.tsx +53 -38
  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 +22 -5
  24. package/src/components/ConfirmDialog.tsx +9 -1
  25. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  26. package/src/components/DiffSheet.stories.tsx +18 -2
  27. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  28. package/src/components/DiffSheet.tsx +184 -98
  29. package/src/components/InboxList.stories.tsx +16 -3
  30. package/src/components/InboxList.tsx +17 -10
  31. package/src/components/KeybindsDialog.tsx +19 -3
  32. package/src/components/MarkerPopover.stories.tsx +58 -0
  33. package/src/components/MarkerPopover.tsx +102 -0
  34. package/src/components/MenuBar.stories.tsx +5 -1
  35. package/src/components/MenuBar.tsx +18 -2
  36. package/src/components/PlanSheet.stories.tsx +34 -2
  37. package/src/components/PlanSheet.tsx +141 -32
  38. package/src/components/PromptDialog.tsx +18 -2
  39. package/src/components/ReviewPanel.stories.tsx +13 -3
  40. package/src/components/ReviewPanel.tsx +8 -4
  41. package/src/components/ReviewRail.stories.tsx +145 -72
  42. package/src/components/ReviewRail.tsx +177 -38
  43. package/src/components/SettingsDialog.stories.tsx +51 -3
  44. package/src/components/SettingsDialog.tsx +8 -3
  45. package/src/components/SettingsRows.tsx +19 -3
  46. package/src/components/Toast.tsx +17 -2
  47. package/src/components/WalkWizard.stories.tsx +13 -2
  48. package/src/components/WalkWizard.tsx +9 -3
  49. package/src/components/__snapshots__/stories.test.tsx.snap +281 -83
  50. package/src/components/diff-sheet-layout.test.ts +127 -0
  51. package/src/components/diff-sheet-layout.ts +167 -0
  52. package/src/components/primitives/Button.tsx +5 -1
  53. package/src/components/primitives/Dialog.tsx +9 -1
  54. package/src/components/primitives/Tabs.tsx +24 -4
  55. package/src/components/stories-app.tsx +34 -6
  56. package/src/components/story-fixtures.ts +11 -3
  57. package/src/components/story.ts +3 -1
  58. package/src/components/syntax-highlight.ts +47 -12
  59. package/src/components/theme-context.stories.tsx +6 -1
  60. package/src/components/theme-context.tsx +7 -1
  61. package/src/config.test.ts +178 -2
  62. package/src/config.ts +121 -23
  63. package/src/diff-hunk-curate.test.ts +315 -0
  64. package/src/diff-hunk-curate.ts +233 -0
  65. package/src/diff-intraline.test.ts +164 -0
  66. package/src/diff-intraline.ts +132 -0
  67. package/src/diff-syntax.test.ts +96 -0
  68. package/src/diff-syntax.ts +154 -0
  69. package/src/editor.test.ts +13 -4
  70. package/src/editor.ts +31 -5
  71. package/src/herdr.ts +8 -2
  72. package/src/index.ts +9 -1
  73. package/src/integrations.ts +3 -1
  74. package/src/intent-dispatch.test.ts +193 -9
  75. package/src/intent-dispatch.ts +151 -18
  76. package/src/key-bindings.test.ts +19 -9
  77. package/src/key-bindings.ts +36 -14
  78. package/src/keymap.test.ts +171 -26
  79. package/src/keymap.ts +83 -16
  80. package/src/review-panel.test.ts +3 -1
  81. package/src/review-panel.ts +9 -2
  82. package/src/serve.test.ts +11 -3
  83. package/src/session-controller.curate.test.ts +253 -0
  84. package/src/session-controller.share.test.ts +65 -17
  85. package/src/session-controller.ts +301 -26
  86. package/src/share.ts +41 -8
  87. package/src/test-support.ts +16 -6
  88. package/src/theme-presets.ts +168 -0
  89. package/src/theme.ts +1 -3
  90. package/src/view-diff.ts +15 -4
  91. package/src/view-plan.test.ts +40 -6
  92. package/src/view-plan.ts +71 -28
  93. package/src/walk.test.ts +7 -2
  94. package/src/walk.ts +7 -2
@@ -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 · enter 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 · enter 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 · enter 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 ←/→ · enter 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,7 +24,15 @@ 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
37
  export interface CheatsheetEntry {
30
38
  keys: string;
@@ -35,9 +43,7 @@ export interface CheatsheetSection {
35
43
  entries: CheatsheetEntry[];
36
44
  }
37
45
 
38
- type HintEntry =
39
- | { text: string }
40
- | { commands: string[]; label: string; labelFirst?: boolean };
46
+ type HintEntry = { text: string } | { commands: string[]; label: string; labelFirst?: boolean };
41
47
 
42
48
  const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
43
49
  normal: [
@@ -45,7 +51,6 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
45
51
  { commands: ["span"], label: "span" },
46
52
  { text: "drag selects" },
47
53
  { commands: ["comment"], label: "comment" },
48
- { commands: ["suggest"], label: "suggest" },
49
54
  { commands: ["cut"], label: "cut" },
50
55
  { commands: ["edit"], label: "edit" },
51
56
  { commands: ["next_annotation", "prev_annotation"], label: "annotations" },
@@ -58,7 +63,6 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
58
63
  { commands: ["span"], label: "span" },
59
64
  { text: "drag selects" },
60
65
  { commands: ["comment"], label: "comment" },
61
- { commands: ["suggest"], label: "suggest" },
62
66
  { commands: ["next_annotation", "prev_annotation"], label: "annotations" },
63
67
  { commands: ["quit"], label: "quit" },
64
68
  ],
@@ -76,7 +80,6 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
76
80
  { commands: ["span_slide_forward", "span_slide_back"], label: "slide" },
77
81
  { commands: ["span_to_end"], label: "end" },
78
82
  { commands: ["span_comment"], label: "comment" },
79
- { commands: ["span_suggest"], label: "suggest" },
80
83
  { commands: ["span_cancel"], label: "" },
81
84
  ],
82
85
  compose: [
@@ -182,7 +185,10 @@ class HeadlessKeymapHost implements KeymapHost<object, KeymapEvent> {
182
185
  function normalizedEvent(key: ResolvableKey): { name: string; shift: boolean } {
183
186
  if (key.name.length === 1) {
184
187
  if (/[a-zA-Z]/.test(key.name)) {
185
- 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
+ };
186
192
  }
187
193
  return { name: key.name, shift: false };
188
194
  }
@@ -204,7 +210,6 @@ const SPAN_COMMANDS: [key: string, command: string][] = [
204
210
  ["$", "span_to_end"],
205
211
  ["0", "span_to_start"],
206
212
  ["c", "span_comment"],
207
- ["s", "span_suggest"],
208
213
  ["escape", "span_cancel"],
209
214
  ];
210
215
 
@@ -281,7 +286,13 @@ export class KeyBindings {
281
286
  { key: "enter", cmd: "walk_submit" },
282
287
  { key: "q", cmd: "quit" },
283
288
  ],
284
- 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
+ ],
285
296
  });
286
297
  this.keymap.registerLayer({
287
298
  priority: 20,
@@ -312,7 +323,9 @@ export class KeyBindings {
312
323
  });
313
324
  this.keymap.registerLayer({
314
325
  priority: 20,
315
- 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",
316
329
  bindings: [
317
330
  { key: "return", cmd: "finish_review" },
318
331
  { key: "enter", cmd: "finish_review" },
@@ -320,7 +333,11 @@ export class KeyBindings {
320
333
  { key: "a", cmd: "opt_in_auto_close" },
321
334
  { key: "escape", cmd: "dismiss_completion" },
322
335
  ],
323
- 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
+ ],
324
341
  });
325
342
  }
326
343
 
@@ -344,7 +361,9 @@ export class KeyBindings {
344
361
  private keyDisplayFor(command: string): string | null {
345
362
  for (const activeKey of this.keymap.getActiveKeys({ includeBindings: true })) {
346
363
  const bindingCommand =
347
- typeof activeKey.command === "string" ? activeKey.command : activeKey.bindings?.[0]?.command;
364
+ typeof activeKey.command === "string"
365
+ ? activeKey.command
366
+ : activeKey.bindings?.[0]?.command;
348
367
  if (bindingCommand !== command) continue;
349
368
  return KEY_GLYPHS[activeKey.stroke.name] ?? activeKey.display;
350
369
  }
@@ -365,7 +384,10 @@ export class KeyBindings {
365
384
  if (!displays.length) continue;
366
385
  const keyPart = displays.join("/");
367
386
  if (!entry.label) fragments.push(keyPart);
368
- 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
+ );
369
391
  }
370
392
  return fragments.join(" · ");
371
393
  }
@@ -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
  });