@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
package/src/index.ts CHANGED
@@ -1,4 +1,12 @@
1
1
  export { runClient, type RunClientOptions } from "./run";
2
2
  export { serveClient, type ServeHandle, type ServeOptions } from "./serve";
3
3
  export { App, type AppProps } from "./App";
4
- export { collaboratorAnnotations, publishShare, pullShare, pushShare, shareIdFromLine, type ShareResult, type ShareTarget } from "./share";
4
+ export {
5
+ collaboratorAnnotations,
6
+ publishShare,
7
+ pullShare,
8
+ pushShare,
9
+ shareIdFromLine,
10
+ type ShareResult,
11
+ type ShareTarget,
12
+ } from "./share";
@@ -18,7 +18,9 @@ export interface BundledExporter {
18
18
  }
19
19
 
20
20
  /** Load the bundled integrations from config as generic exporters. */
21
- export async function loadBundledExporters(integrations: IntegrationsConfig): Promise<BundledExporter[]> {
21
+ export async function loadBundledExporters(
22
+ integrations: IntegrationsConfig,
23
+ ): Promise<BundledExporter[]> {
22
24
  const registry = new Registry();
23
25
  const obsidian = integrations.obsidian;
24
26
  const record = await registry.load("obsidian", createObsidianExtension(obsidian));
@@ -21,14 +21,16 @@ function sessionWith(annotationIds: string[]): ReviewSession {
21
21
  } as unknown as ReviewSession;
22
22
  }
23
23
 
24
- /** A deps bag where every effect is a mock and every read has a plain default. */
25
- function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDeps {
26
- const controller = {
24
+ /** A controller where every verb is a mock; annotate returns undefined by default. */
25
+ function baseController() {
26
+ return {
27
27
  setStatus: mock(),
28
28
  open: mock(),
29
29
  deleteSession: mock(),
30
30
  setSelfName: mock(),
31
31
  cut: mock(),
32
+ curationItems: mock(() => []),
33
+ restoreCuration: mock(),
32
34
  annotate: mock(),
33
35
  updateAnnotation: mock(),
34
36
  removeAnnotation: mock(),
@@ -43,6 +45,11 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
43
45
  dismissCompletion: mock(),
44
46
  saveReviewPanel: mock(),
45
47
  };
48
+ }
49
+
50
+ /** A deps bag where every effect is a mock and every read has a plain default. */
51
+ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDeps {
52
+ const controller = baseController();
46
53
  return {
47
54
  controller: controller as unknown as IntentDispatchDeps["controller"],
48
55
  onExit: mock(),
@@ -58,7 +65,9 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
58
65
  reviewWidth: 34,
59
66
  terminalWidth: 120,
60
67
  focusedAnnotationId: undefined,
68
+ selectedCurationId: undefined,
61
69
  authorNames: {},
70
+ quickActions: [],
62
71
  renameAuthor: mock(),
63
72
  liveInput: { current: "" },
64
73
  reviewWidthRef: { current: 34 },
@@ -70,6 +79,7 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
70
79
  setReviewWidth: mock(),
71
80
  setRailTab: mock(),
72
81
  setFocusedAnnotationId: mock(),
82
+ setSelectedCurationId: mock(),
73
83
  setPulsedAnnotationId: mock(),
74
84
  selectCardFromDocument: mock(),
75
85
  runEditorHandOff: mock(),
@@ -88,7 +98,9 @@ describe("move", () => {
88
98
  dispatch({ type: "move", to: "down" });
89
99
 
90
100
  // Assert
91
- const advance = (deps.setCursor as ReturnType<typeof mock>).mock.calls[0]![0] as (current: number) => number;
101
+ const advance = (deps.setCursor as ReturnType<typeof mock>).mock.calls[0]![0] as (
102
+ current: number,
103
+ ) => number;
92
104
  expect(advance(2)).toBe(2); // already at the end, stays
93
105
  expect(advance(0)).toBe(1);
94
106
  });
@@ -134,7 +146,10 @@ describe("annotation navigation", () => {
134
146
  test("cycling skips annotations a revision already addressed", () => {
135
147
  // Arrange: a2 is addressed, so next from a1 lands on a3
136
148
  const session = sessionWith(["a1", "a2", "a3"]);
137
- (session.annotations[1] as { resolution?: object }).resolution = { revision: 2, source: "agent" };
149
+ (session.annotations[1] as { resolution?: object }).resolution = {
150
+ revision: 2,
151
+ source: "agent",
152
+ };
138
153
  const deps = makeDeps({ session, focusedAnnotationId: "a1" });
139
154
  const dispatch = createIntentDispatch(deps);
140
155
 
@@ -156,7 +171,105 @@ describe("cycleVerdict", () => {
156
171
  dispatch({ type: "cycleVerdict", direction: 1 });
157
172
 
158
173
  // Assert
159
- expect(deps.setMode).toHaveBeenCalledWith({ type: "submit", verdict: "request_changes", summary: "" });
174
+ expect(deps.setMode).toHaveBeenCalledWith({
175
+ type: "submit",
176
+ verdict: "request_changes",
177
+ summary: "",
178
+ });
179
+ });
180
+ });
181
+
182
+ describe("marker-actions popover", () => {
183
+ const span = { displayIndex: 3, wordIndex: 0, wordEnd: 0, start: 2, end: 9 };
184
+
185
+ test("spanCut cuts the span's block and closes the popover", () => {
186
+ // Arrange
187
+ const deps = makeDeps({ mode: { type: "span", span } });
188
+ const dispatch = createIntentDispatch(deps);
189
+
190
+ // Act
191
+ dispatch({ type: "spanCut" });
192
+
193
+ // Assert
194
+ expect(deps.controller.cut).toHaveBeenCalledWith(3);
195
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
196
+ });
197
+
198
+ test("openSpanActions enters the list at index 0", () => {
199
+ // Arrange
200
+ const deps = makeDeps({ mode: { type: "span", span } });
201
+ const dispatch = createIntentDispatch(deps);
202
+
203
+ // Act
204
+ dispatch({ type: "openSpanActions" });
205
+
206
+ // Assert
207
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "spanActions", span, index: 0 });
208
+ });
209
+
210
+ test("moveSpanAction clamps within the actions list", () => {
211
+ // Arrange
212
+ const deps = makeDeps({
213
+ mode: { type: "spanActions", span, index: 1 },
214
+ quickActions: [{ prompt: "one" }, { prompt: "two" }],
215
+ });
216
+ const dispatch = createIntentDispatch(deps);
217
+
218
+ // Act
219
+ dispatch({ type: "moveSpanAction", direction: 1 });
220
+
221
+ // Assert - already at the last row, so the index holds
222
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "spanActions", span, index: 1 });
223
+ });
224
+
225
+ test("pickSpanAction inserts the preset comment and returns to normal", () => {
226
+ // Arrange
227
+ const annotate = mock(() => "an_new");
228
+ const deps = makeDeps({
229
+ mode: { type: "spanActions", span, index: 1 },
230
+ session: sessionWith([]),
231
+ quickActions: [{ prompt: "Needs a test" }, { prompt: "YAGNI", metadata: "cut scope" }],
232
+ controller: { ...baseController(), annotate } as unknown as IntentDispatchDeps["controller"],
233
+ });
234
+ const dispatch = createIntentDispatch(deps);
235
+
236
+ // Act
237
+ dispatch({ type: "pickSpanAction" });
238
+
239
+ // Assert - metadata joins the prompt with a blank line
240
+ expect(annotate).toHaveBeenCalledWith("comment", 3, 2, 9, "YAGNI\n\ncut scope");
241
+ expect(deps.setFocusedAnnotationId).toHaveBeenCalledWith("an_new");
242
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
243
+ });
244
+
245
+ test("pickSpanAction honors an explicit index (mouse click)", () => {
246
+ // Arrange
247
+ const annotate = mock(() => "an_new");
248
+ const deps = makeDeps({
249
+ mode: { type: "spanActions", span, index: 0 },
250
+ session: sessionWith([]),
251
+ quickActions: [{ prompt: "Needs a test" }, { prompt: "Extract the duplication" }],
252
+ controller: { ...baseController(), annotate } as unknown as IntentDispatchDeps["controller"],
253
+ });
254
+ const dispatch = createIntentDispatch(deps);
255
+
256
+ // Act
257
+ dispatch({ type: "pickSpanAction", index: 1 });
258
+
259
+ // Assert
260
+ expect(annotate).toHaveBeenCalledWith("comment", 3, 2, 9, "Extract the duplication");
261
+ });
262
+
263
+ test("closeSpanActions returns to the span toolbar", () => {
264
+ // Arrange
265
+ const deps = makeDeps({ mode: { type: "spanActions", span, index: 2 } });
266
+ const dispatch = createIntentDispatch(deps);
267
+
268
+ // Act
269
+ dispatch({ type: "closeSpanActions" });
270
+
271
+ // Assert
272
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "span", span });
160
273
  });
161
274
  });
162
275
 
@@ -202,6 +315,65 @@ describe("share", () => {
202
315
  });
203
316
  });
204
317
 
318
+ describe("restoreCuration", () => {
319
+ const items = [
320
+ {
321
+ id: "diff:f#0#hunk",
322
+ source: "diff" as const,
323
+ label: "f:1 - hunk",
324
+ preview: [],
325
+ revealIndex: 1,
326
+ },
327
+ {
328
+ id: "diff:f#1#2",
329
+ source: "diff" as const,
330
+ label: "f:9 - change",
331
+ preview: [],
332
+ revealIndex: 9,
333
+ },
334
+ ];
335
+
336
+ test("restores the selected item and clears the selection", () => {
337
+ // Arrange
338
+ const deps = makeDeps({ selectedCurationId: "diff:f#1#2" });
339
+ (deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue(items);
340
+ const dispatch = createIntentDispatch(deps);
341
+
342
+ // Act
343
+ dispatch({ type: "restoreCuration" });
344
+
345
+ // Assert
346
+ expect(deps.controller.restoreCuration).toHaveBeenCalledWith("diff:f#1#2");
347
+ expect(deps.setSelectedCurationId).toHaveBeenCalledWith(undefined);
348
+ });
349
+
350
+ test("with nothing selected, undoes the last removal", () => {
351
+ // Arrange
352
+ const deps = makeDeps({ selectedCurationId: undefined });
353
+ (deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue(items);
354
+ const dispatch = createIntentDispatch(deps);
355
+
356
+ // Act
357
+ dispatch({ type: "restoreCuration" });
358
+
359
+ // Assert
360
+ expect(deps.controller.restoreCuration).toHaveBeenCalledWith("diff:f#1#2");
361
+ });
362
+
363
+ test("does nothing when there is nothing curated out", () => {
364
+ // Arrange
365
+ const deps = makeDeps();
366
+ (deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue([]);
367
+ const dispatch = createIntentDispatch(deps);
368
+
369
+ // Act
370
+ dispatch({ type: "restoreCuration" });
371
+
372
+ // Assert
373
+ expect(deps.controller.restoreCuration).not.toHaveBeenCalled();
374
+ });
375
+ });
376
+
205
377
  describe("exit", () => {
206
378
  test("calls the exit hook with 0", () => {
207
379
  // Arrange
@@ -226,7 +398,11 @@ describe("inbox delete", () => {
226
398
  createIntentDispatch(deps)({ type: "requestDeleteSession" });
227
399
 
228
400
  // Assert
229
- expect(deps.setMode).toHaveBeenCalledWith({ type: "confirmDelete", sessionId: "ses_1", title: "Plan A" });
401
+ expect(deps.setMode).toHaveBeenCalledWith({
402
+ type: "confirmDelete",
403
+ sessionId: "ses_1",
404
+ title: "Plan A",
405
+ });
230
406
  });
231
407
 
232
408
  test("confirmDialog deletes the session and closes", () => {
@@ -246,13 +422,21 @@ describe("rename author", () => {
246
422
  test("openRename seeds the prompt with the author's current local name", () => {
247
423
  // Arrange
248
424
  const session = { annotations: [{ id: "a1", author: "SHA256:x" }] } as never;
249
- const deps = makeDeps({ session, focusedAnnotationId: "a1", authorNames: { "SHA256:x": "Alex" } });
425
+ const deps = makeDeps({
426
+ session,
427
+ focusedAnnotationId: "a1",
428
+ authorNames: { "SHA256:x": "Alex" },
429
+ });
250
430
 
251
431
  // Act
252
432
  createIntentDispatch(deps)({ type: "openRename" });
253
433
 
254
434
  // Assert
255
- expect(deps.setMode).toHaveBeenCalledWith({ type: "rename", authorId: "SHA256:x", text: "Alex" });
435
+ expect(deps.setMode).toHaveBeenCalledWith({
436
+ type: "rename",
437
+ authorId: "SHA256:x",
438
+ text: "Alex",
439
+ });
256
440
  });
257
441
 
258
442
  test("openRename on your own note does nothing but explain", () => {
@@ -13,32 +13,55 @@ import { displayText, spanKey, startSpan, type DisplayBlock, type SpanState } fr
13
13
  import type { DiffRow } from "./view-diff";
14
14
  import type { ReviewController } from "./session-controller";
15
15
  import type { Intent } from "./keymap";
16
+ import type { QuickAction } from "./config";
16
17
  import type { PlanSheetHandle } from "./components/PlanSheet";
17
18
  import { VERDICTS } from "./components/ConfirmCard";
18
- import { REVIEW_RESIZE_STEP, cycleReviewPanelMode, resolveReviewWidth, type ReviewPanelMode } from "./review-panel";
19
+ import {
20
+ REVIEW_RESIZE_STEP,
21
+ cycleReviewPanelMode,
22
+ resolveReviewWidth,
23
+ type ReviewPanelMode,
24
+ } from "./review-panel";
19
25
 
20
26
  /** The one overlay/mode the TUI is in; every compose/submit/edit flow is a variant. */
21
27
  export type Mode =
22
28
  | { type: "normal" }
23
29
  | { type: "span"; span: SpanState }
24
- | { type: "compose"; kind: "comment" | "suggestion"; displayIndex: number; start: number; end: number; text: string }
30
+ | { type: "spanActions"; span: SpanState; index: number }
31
+ | {
32
+ type: "compose";
33
+ kind: "comment";
34
+ displayIndex: number;
35
+ start: number;
36
+ end: number;
37
+ text: string;
38
+ }
25
39
  | { type: "railEdit"; id: string; text: string }
26
40
  | { type: "submit"; verdict: VerdictKind; summary: string }
27
41
  | { type: "confirmDelete"; sessionId: string; title: string }
28
42
  | { type: "rename"; authorId: string; text: string }
29
43
  | { type: "nameSelf"; text: string };
30
44
 
45
+ /** The marked span for span mode and its quick-actions sub-mode; null otherwise. */
46
+ export function activeSpanState(mode: Mode): SpanState | null {
47
+ return mode.type === "span" || mode.type === "spanActions" ? mode.span : null;
48
+ }
49
+
31
50
  /**
32
51
  * Annotations that still count as feedback: agent notes never do, and an
33
52
  * annotation a revision already addressed is settled - it neither blocks the
34
53
  * verdict default nor re-enters the next feedback document.
35
54
  */
36
55
  export function reviewerAnnotations(session: ReviewSession) {
37
- return session.annotations.filter((annotation) => !isAgentNote(annotation) && !isAddressed(annotation));
56
+ return session.annotations.filter(
57
+ (annotation) => !isAgentNote(annotation) && !isAddressed(annotation),
58
+ );
38
59
  }
39
60
 
40
61
  export function defaultVerdict(session: ReviewSession): VerdictKind {
41
- return reviewerAnnotations(session).length || session.workingCopy !== undefined ? "request_changes" : "approve";
62
+ return reviewerAnnotations(session).length || session.workingCopy !== undefined
63
+ ? "request_changes"
64
+ : "approve";
42
65
  }
43
66
 
44
67
  /**
@@ -63,8 +86,12 @@ export interface IntentDispatchDeps {
63
86
  reviewWidth: number;
64
87
  terminalWidth: number;
65
88
  focusedAnnotationId: string | undefined;
89
+ /** The curation item selected in the rail, if any; the undo target when set. */
90
+ selectedCurationId: string | undefined;
66
91
  /** Planner-local author renames, for seeding the rename prompt. */
67
92
  authorNames: Record<string, string>;
93
+ /** Marker-popover quick actions, in list order; picking one inserts a preset comment. */
94
+ quickActions: QuickAction[];
68
95
  /** Persist an author rename and update the live overrides (App-owned). */
69
96
  renameAuthor: (id: string, name: string) => void;
70
97
 
@@ -79,6 +106,7 @@ export interface IntentDispatchDeps {
79
106
  setReviewWidth: Dispatch<SetStateAction<number>>;
80
107
  setRailTab: Dispatch<SetStateAction<"review" | "agent">>;
81
108
  setFocusedAnnotationId: Dispatch<SetStateAction<string | undefined>>;
109
+ setSelectedCurationId: Dispatch<SetStateAction<string | undefined>>;
82
110
  setPulsedAnnotationId: Dispatch<SetStateAction<string | null>>;
83
111
 
84
112
  selectCardFromDocument: (annotationId: string) => void;
@@ -103,7 +131,9 @@ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent)
103
131
  reviewWidth,
104
132
  terminalWidth,
105
133
  focusedAnnotationId,
134
+ selectedCurationId,
106
135
  authorNames,
136
+ quickActions,
107
137
  renameAuthor,
108
138
  liveInput,
109
139
  reviewWidthRef,
@@ -115,6 +145,7 @@ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent)
115
145
  setReviewWidth,
116
146
  setRailTab,
117
147
  setFocusedAnnotationId,
148
+ setSelectedCurationId,
118
149
  setPulsedAnnotationId,
119
150
  selectCardFromDocument,
120
151
  runEditorHandOff,
@@ -137,7 +168,11 @@ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent)
137
168
  }
138
169
  case "inboxMove": {
139
170
  const navigableCount = inbox?.length ?? 0;
140
- setInboxCursor((current) => (intent.to === "down" ? Math.min(navigableCount - 1, current + 1) : Math.max(0, current - 1)));
171
+ setInboxCursor((current) =>
172
+ intent.to === "down"
173
+ ? Math.min(navigableCount - 1, current + 1)
174
+ : Math.max(0, current - 1),
175
+ );
141
176
  return;
142
177
  }
143
178
  case "openSession": {
@@ -147,13 +182,25 @@ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent)
147
182
  }
148
183
  case "requestDeleteSession": {
149
184
  const selected = inbox?.[inboxCursor];
150
- if (selected) setMode({ type: "confirmDelete", sessionId: selected.id, title: selected.artifact.meta.title ?? selected.id });
185
+ if (selected)
186
+ setMode({
187
+ type: "confirmDelete",
188
+ sessionId: selected.id,
189
+ title: selected.artifact.meta.title ?? selected.id,
190
+ });
151
191
  return;
152
192
  }
153
193
  case "openRename": {
154
- const focused = session?.annotations.find((annotation) => annotation.id === focusedAnnotationId);
155
- if (!focused?.author) return void controller.setStatus("that is your own note - nothing to rename");
156
- return void setMode({ type: "rename", authorId: focused.author, text: authorNames[focused.author] ?? "" });
194
+ const focused = session?.annotations.find(
195
+ (annotation) => annotation.id === focusedAnnotationId,
196
+ );
197
+ if (!focused?.author)
198
+ return void controller.setStatus("that is your own note - nothing to rename");
199
+ return void setMode({
200
+ type: "rename",
201
+ authorId: focused.author,
202
+ text: authorNames[focused.author] ?? "",
203
+ });
157
204
  }
158
205
  case "confirmDialog": {
159
206
  if (mode.type === "confirmDelete") controller.deleteSession(mode.sessionId);
@@ -170,10 +217,52 @@ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent)
170
217
  }
171
218
  case "spanKey":
172
219
  if (mode.type === "span") {
173
- const span = spanKey(mode.span, intent.name, displayText(display[mode.span.displayIndex]!));
220
+ const span = spanKey(
221
+ mode.span,
222
+ intent.name,
223
+ displayText(display[mode.span.displayIndex]!),
224
+ );
174
225
  setMode({ type: "span", span });
175
226
  }
176
227
  return;
228
+ case "spanCut":
229
+ // the block the span sits in, cut whole (partial-span cut is not modeled)
230
+ if (mode.type === "span") {
231
+ controller.cut(mode.span.displayIndex);
232
+ setMode({ type: "normal" });
233
+ }
234
+ return;
235
+ case "openSpanActions":
236
+ if (mode.type === "span") setMode({ type: "spanActions", span: mode.span, index: 0 });
237
+ return;
238
+ case "moveSpanAction":
239
+ if (mode.type === "spanActions") {
240
+ const index = Math.max(
241
+ 0,
242
+ Math.min(quickActions.length - 1, mode.index + intent.direction),
243
+ );
244
+ setMode({ ...mode, index });
245
+ }
246
+ return;
247
+ case "closeSpanActions":
248
+ if (mode.type === "spanActions") setMode({ type: "span", span: mode.span });
249
+ return;
250
+ case "pickSpanAction": {
251
+ if (mode.type !== "spanActions") return;
252
+ const action = quickActions[intent.index ?? mode.index];
253
+ if (session && action) {
254
+ const body = action.metadata ? `${action.prompt}\n\n${action.metadata}` : action.prompt;
255
+ const annotationId = controller.annotate(
256
+ "comment",
257
+ mode.span.displayIndex,
258
+ mode.span.start,
259
+ mode.span.end,
260
+ body,
261
+ );
262
+ if (annotationId) setFocusedAnnotationId(annotationId);
263
+ }
264
+ return void setMode({ type: "normal" });
265
+ }
177
266
  case "openCompose": {
178
267
  liveInput.current = "";
179
268
  if (intent.from === "span" && mode.type === "span") {
@@ -187,7 +276,15 @@ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent)
187
276
  });
188
277
  } else if (isDiff) {
189
278
  const row = rows[cursor];
190
- if (row) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: row.text.length, text: "" });
279
+ if (row)
280
+ setMode({
281
+ type: "compose",
282
+ kind: intent.kind,
283
+ displayIndex: cursor,
284
+ start: 0,
285
+ end: row.text.length,
286
+ text: "",
287
+ });
191
288
  } else {
192
289
  // a mouse drag leaves a native selection; it wins over the cursor block
193
290
  const native = planSheetRef.current?.readSelection() ?? null;
@@ -195,7 +292,15 @@ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent)
195
292
  setMode({ type: "compose", kind: intent.kind, ...native, text: "" });
196
293
  } else {
197
294
  const block = display[cursor];
198
- if (block) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: displayText(block).length, text: "" });
295
+ if (block)
296
+ setMode({
297
+ type: "compose",
298
+ kind: intent.kind,
299
+ displayIndex: cursor,
300
+ start: 0,
301
+ end: displayText(block).length,
302
+ text: "",
303
+ });
199
304
  }
200
305
  }
201
306
  return;
@@ -213,6 +318,19 @@ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent)
213
318
  return controller.share();
214
319
  case "cut":
215
320
  return controller.cut(cursor);
321
+ case "rejectHunk":
322
+ return controller.toggleRejectHunk(cursor);
323
+ case "rejectChange":
324
+ return controller.toggleRejectChange(cursor);
325
+ case "restoreCuration": {
326
+ // undo the selected curated-out item, or the last rejected when none is
327
+ // selected, so a bare `u` reads as "undo my last curation"
328
+ const items = controller.curationItems();
329
+ if (!items.length) return;
330
+ const targetId = selectedCurationId ?? items[items.length - 1]!.id;
331
+ controller.restoreCuration(targetId);
332
+ return void setSelectedCurationId(undefined);
333
+ }
216
334
  case "edit":
217
335
  return runEditorHandOff();
218
336
  case "editCard":
@@ -221,13 +339,18 @@ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent)
221
339
  case "nextAnnotation":
222
340
  case "prevAnnotation": {
223
341
  // cycle open cards only - addressed ones are out of the rail
224
- const annotations = (session?.annotations ?? []).filter((annotation) => !isAddressed(annotation));
342
+ const annotations = (session?.annotations ?? []).filter(
343
+ (annotation) => !isAddressed(annotation),
344
+ );
225
345
  if (!annotations.length) return;
226
- const focusedIndex = annotations.findIndex((annotation) => annotation.id === focusedAnnotationId);
346
+ const focusedIndex = annotations.findIndex(
347
+ (annotation) => annotation.id === focusedAnnotationId,
348
+ );
227
349
  const nextIndex =
228
350
  focusedIndex === -1
229
351
  ? 0
230
- : (focusedIndex + (intent.type === "nextAnnotation" ? 1 : -1) + annotations.length) % annotations.length;
352
+ : (focusedIndex + (intent.type === "nextAnnotation" ? 1 : -1) + annotations.length) %
353
+ annotations.length;
231
354
  return void selectCardFromDocument(annotations[nextIndex]!.id);
232
355
  }
233
356
  case "walkStart":
@@ -259,7 +382,13 @@ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent)
259
382
  }
260
383
  if (mode.type !== "compose") return;
261
384
  if (session && body) {
262
- const annotationId = controller.annotate(mode.kind, mode.displayIndex, mode.start, mode.end, body);
385
+ const annotationId = controller.annotate(
386
+ mode.kind,
387
+ mode.displayIndex,
388
+ mode.start,
389
+ mode.end,
390
+ body,
391
+ );
263
392
  if (annotationId) setFocusedAnnotationId(annotationId);
264
393
  }
265
394
  return void setMode({ type: "normal" });
@@ -269,7 +398,8 @@ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent)
269
398
  return void setMode({ type: "normal" });
270
399
  case "cycleVerdict": {
271
400
  if (mode.type !== "submit") return;
272
- const verdictIndex = (VERDICTS.indexOf(mode.verdict) + intent.direction + VERDICTS.length) % VERDICTS.length;
401
+ const verdictIndex =
402
+ (VERDICTS.indexOf(mode.verdict) + intent.direction + VERDICTS.length) % VERDICTS.length;
273
403
  return void setMode({ ...mode, verdict: VERDICTS[verdictIndex]! });
274
404
  }
275
405
  case "finishReview":
@@ -285,7 +415,10 @@ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent)
285
415
  }
286
416
  case "resizeReviewPanel": {
287
417
  if (reviewMode !== "expanded") return;
288
- const next = resolveReviewWidth(reviewWidth + intent.direction * REVIEW_RESIZE_STEP, terminalWidth);
418
+ const next = resolveReviewWidth(
419
+ reviewWidth + intent.direction * REVIEW_RESIZE_STEP,
420
+ terminalWidth,
421
+ );
289
422
  reviewWidthRef.current = next;
290
423
  setReviewWidth(next);
291
424
  return controller.saveReviewPanel({ width: next });