@cueloop/client 0.1.0-alpha.4 → 0.1.0-alpha.40

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 (131) hide show
  1. package/package.json +32 -24
  2. package/src/App.autoclose.test.tsx +137 -0
  3. package/src/App.diff.test.tsx +158 -22
  4. package/src/App.export.test.tsx +44 -37
  5. package/src/App.plan-review.test.tsx +632 -0
  6. package/src/App.readonly.test.tsx +60 -36
  7. package/src/App.rendering.test.tsx +132 -0
  8. package/src/App.review-panel.test.tsx +136 -0
  9. package/src/App.submit-confirm.test.tsx +220 -0
  10. package/src/App.test.tsx +128 -69
  11. package/src/App.toast.test.tsx +75 -0
  12. package/src/App.tsx +0 -0
  13. package/src/App.walk.test.tsx +288 -0
  14. package/src/attribution.test.ts +31 -0
  15. package/src/attribution.ts +38 -0
  16. package/src/clipboard.test.ts +23 -0
  17. package/src/clipboard.ts +30 -0
  18. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  19. package/src/components/AnnotationCard.stories.tsx +94 -0
  20. package/src/components/AnnotationCard.tsx +228 -0
  21. package/src/components/Breadcrumb.stories.tsx +19 -0
  22. package/src/components/Breadcrumb.tsx +44 -0
  23. package/src/components/CodeBlock.stories.tsx +42 -0
  24. package/src/components/CodeBlock.tsx +83 -0
  25. package/src/components/CompletionOverlay.stories.tsx +23 -0
  26. package/src/components/CompletionOverlay.tsx +59 -0
  27. package/src/components/ConfirmCard.stories.tsx +56 -0
  28. package/src/components/ConfirmCard.tsx +135 -0
  29. package/src/components/ConfirmDialog.stories.tsx +21 -0
  30. package/src/components/ConfirmDialog.tsx +64 -0
  31. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  32. package/src/components/DiffSheet.stories.tsx +78 -0
  33. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  34. package/src/components/DiffSheet.tsx +355 -0
  35. package/src/components/InboxList.stories.tsx +31 -0
  36. package/src/components/InboxList.tsx +62 -0
  37. package/src/components/KeybindsDialog.stories.tsx +31 -0
  38. package/src/components/KeybindsDialog.tsx +58 -0
  39. package/src/components/MarkerPopover.stories.tsx +58 -0
  40. package/src/components/MarkerPopover.tsx +102 -0
  41. package/src/components/MenuBar.stories.tsx +37 -0
  42. package/src/components/MenuBar.tsx +74 -0
  43. package/src/components/PlanSheet.stories.tsx +113 -0
  44. package/src/components/PlanSheet.tsx +389 -0
  45. package/src/components/PromptDialog.stories.tsx +36 -0
  46. package/src/components/PromptDialog.tsx +78 -0
  47. package/src/components/ReviewPanel.stories.tsx +86 -0
  48. package/src/components/ReviewPanel.tsx +101 -0
  49. package/src/components/ReviewRail.stories.tsx +220 -0
  50. package/src/components/ReviewRail.tsx +316 -0
  51. package/src/components/SettingsDialog.stories.tsx +111 -0
  52. package/src/components/SettingsDialog.tsx +141 -0
  53. package/src/components/SettingsRows.stories.tsx +18 -0
  54. package/src/components/SettingsRows.tsx +108 -0
  55. package/src/components/Toast.stories.tsx +12 -0
  56. package/src/components/Toast.tsx +44 -0
  57. package/src/components/WalkWizard.stories.tsx +100 -0
  58. package/src/components/WalkWizard.tsx +152 -0
  59. package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
  60. package/src/components/agent-launcher.stories.tsx +26 -0
  61. package/src/components/agent-launcher.test.tsx +21 -0
  62. package/src/components/agent-launcher.tsx +123 -0
  63. package/src/components/diff-sheet-layout.test.ts +127 -0
  64. package/src/components/diff-sheet-layout.ts +167 -0
  65. package/src/components/primitives/Button.stories.tsx +32 -0
  66. package/src/components/primitives/Button.tsx +48 -0
  67. package/src/components/primitives/Card.stories.tsx +25 -0
  68. package/src/components/primitives/Card.tsx +59 -0
  69. package/src/components/primitives/Dialog.stories.tsx +24 -0
  70. package/src/components/primitives/Dialog.tsx +64 -0
  71. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  72. package/src/components/primitives/StatusBar.tsx +22 -0
  73. package/src/components/primitives/Tabs.stories.tsx +29 -0
  74. package/src/components/primitives/Tabs.tsx +98 -0
  75. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  76. package/src/components/primitives/Toolbar.tsx +14 -0
  77. package/src/components/primitives/frame.test.ts +12 -0
  78. package/src/components/primitives/frame.ts +14 -0
  79. package/src/components/quick-actions-editor.stories.tsx +54 -0
  80. package/src/components/quick-actions-editor.tsx +136 -0
  81. package/src/components/stories-app.tsx +123 -0
  82. package/src/components/stories.test.tsx +62 -0
  83. package/src/components/story-fixtures.ts +98 -0
  84. package/src/components/story.ts +69 -0
  85. package/src/components/syntax-highlight.ts +92 -0
  86. package/src/components/theme-context.stories.tsx +46 -0
  87. package/src/components/theme-context.tsx +36 -0
  88. package/src/components/truncate-text.ts +6 -0
  89. package/src/config.test.ts +430 -31
  90. package/src/config.ts +304 -26
  91. package/src/diff-hunk-curate.test.ts +315 -0
  92. package/src/diff-hunk-curate.ts +233 -0
  93. package/src/diff-intraline.test.ts +164 -0
  94. package/src/diff-intraline.ts +132 -0
  95. package/src/diff-syntax.test.ts +96 -0
  96. package/src/diff-syntax.ts +154 -0
  97. package/src/editor.test.ts +143 -0
  98. package/src/editor.ts +123 -9
  99. package/src/herdr.test.ts +41 -0
  100. package/src/herdr.ts +27 -0
  101. package/src/index.ts +10 -0
  102. package/src/integrations.test.ts +41 -0
  103. package/src/integrations.ts +30 -0
  104. package/src/intent-dispatch.test.ts +492 -0
  105. package/src/intent-dispatch.ts +428 -0
  106. package/src/key-bindings.test.ts +152 -0
  107. package/src/key-bindings.ts +424 -0
  108. package/src/keymap.test.ts +622 -0
  109. package/src/keymap.ts +339 -0
  110. package/src/review-panel.test.ts +113 -0
  111. package/src/review-panel.ts +83 -0
  112. package/src/run.ts +9 -5
  113. package/src/serve.test.ts +24 -9
  114. package/src/serve.ts +7 -7
  115. package/src/session-controller.curate.test.ts +253 -0
  116. package/src/session-controller.share.test.ts +309 -0
  117. package/src/session-controller.ts +900 -0
  118. package/src/share.ts +113 -0
  119. package/src/test-support.test.ts +55 -0
  120. package/src/test-support.ts +187 -0
  121. package/src/theme-presets.ts +168 -0
  122. package/src/theme.ts +36 -15
  123. package/src/version.ts +5 -0
  124. package/src/view-diff.test.ts +19 -8
  125. package/src/view-diff.ts +34 -23
  126. package/src/view-plan.test.ts +294 -0
  127. package/src/view-plan.ts +408 -0
  128. package/src/walk.test.ts +121 -0
  129. package/src/walk.ts +70 -0
  130. package/src/view.test.ts +0 -157
  131. package/src/view.ts +0 -0
@@ -0,0 +1,428 @@
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 { quickActionBody, type QuickAction } from "./config";
17
+ import type { PlanSheetHandle } from "./components/PlanSheet";
18
+ import { VERDICTS } from "./components/ConfirmCard";
19
+ import {
20
+ REVIEW_RESIZE_STEP,
21
+ cycleReviewPanelMode,
22
+ resolveReviewWidth,
23
+ type ReviewPanelMode,
24
+ } from "./review-panel";
25
+
26
+ /** The one overlay/mode the TUI is in; every compose/submit/edit flow is a variant. */
27
+ export type Mode =
28
+ | { type: "normal" }
29
+ | { type: "span"; span: SpanState }
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
+ }
39
+ | { type: "railEdit"; id: string; text: string }
40
+ | { type: "submit"; verdict: VerdictKind; summary: string }
41
+ | { type: "confirmDelete"; sessionId: string; title: string }
42
+ | { type: "rename"; authorId: string; text: string }
43
+ | { type: "nameSelf"; text: string };
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
+
50
+ /**
51
+ * Annotations that still count as feedback: agent notes never do, and an
52
+ * annotation a revision already addressed is settled - it neither blocks the
53
+ * verdict default nor re-enters the next feedback document.
54
+ */
55
+ export function reviewerAnnotations(session: ReviewSession) {
56
+ return session.annotations.filter(
57
+ (annotation) => !isAgentNote(annotation) && !isAddressed(annotation),
58
+ );
59
+ }
60
+
61
+ export function defaultVerdict(session: ReviewSession): VerdictKind {
62
+ return reviewerAnnotations(session).length || session.workingCopy !== undefined
63
+ ? "request_changes"
64
+ : "approve";
65
+ }
66
+
67
+ /**
68
+ * Everything the reducer reads or writes, supplied fresh each render: the
69
+ * controller and exit hook, the derived reads (view rows, cursor, mode,
70
+ * session), the refs it pokes, the state setters, and the three App-owned
71
+ * selection helpers it composes.
72
+ */
73
+ export interface IntentDispatchDeps {
74
+ controller: ReviewController;
75
+ onExit?: (code: number) => void;
76
+
77
+ isDiff: boolean;
78
+ display: DisplayBlock[];
79
+ rows: DiffRow[];
80
+ cursor: number;
81
+ inbox: ReviewSession[] | null;
82
+ inboxCursor: number;
83
+ mode: Mode;
84
+ session: ReviewSession | null;
85
+ reviewMode: ReviewPanelMode;
86
+ reviewWidth: number;
87
+ terminalWidth: number;
88
+ focusedAnnotationId: string | undefined;
89
+ /** The curation item selected in the rail, if any; the undo target when set. */
90
+ selectedCurationId: string | undefined;
91
+ /** Planner-local author renames, for seeding the rename prompt. */
92
+ authorNames: Record<string, string>;
93
+ /** Marker-popover quick actions, in list order; picking one inserts a preset comment. */
94
+ quickActions: QuickAction[];
95
+ /** Persist an author rename and update the live overrides (App-owned). */
96
+ renameAuthor: (id: string, name: string) => void;
97
+
98
+ liveInput: MutableRefObject<string>;
99
+ reviewWidthRef: MutableRefObject<number>;
100
+ planSheetRef: RefObject<PlanSheetHandle | null>;
101
+
102
+ setCursor: Dispatch<SetStateAction<number>>;
103
+ setInboxCursor: Dispatch<SetStateAction<number>>;
104
+ setMode: Dispatch<SetStateAction<Mode>>;
105
+ setReviewMode: Dispatch<SetStateAction<ReviewPanelMode>>;
106
+ setReviewWidth: Dispatch<SetStateAction<number>>;
107
+ setRailTab: Dispatch<SetStateAction<"review" | "agent">>;
108
+ setFocusedAnnotationId: Dispatch<SetStateAction<string | undefined>>;
109
+ setSelectedCurationId: Dispatch<SetStateAction<string | undefined>>;
110
+ setPulsedAnnotationId: Dispatch<SetStateAction<string | null>>;
111
+
112
+ selectCardFromDocument: (annotationId: string) => void;
113
+ runEditorHandOff: () => void;
114
+ openCardEdit: (annotationId: string) => void;
115
+ }
116
+
117
+ /** Build the intent handler for one render from its dependency bag. */
118
+ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent) => void {
119
+ const {
120
+ controller,
121
+ onExit,
122
+ isDiff,
123
+ display,
124
+ rows,
125
+ cursor,
126
+ inbox,
127
+ inboxCursor,
128
+ mode,
129
+ session,
130
+ reviewMode,
131
+ reviewWidth,
132
+ terminalWidth,
133
+ focusedAnnotationId,
134
+ selectedCurationId,
135
+ authorNames,
136
+ quickActions,
137
+ renameAuthor,
138
+ liveInput,
139
+ reviewWidthRef,
140
+ planSheetRef,
141
+ setCursor,
142
+ setInboxCursor,
143
+ setMode,
144
+ setReviewMode,
145
+ setReviewWidth,
146
+ setRailTab,
147
+ setFocusedAnnotationId,
148
+ setSelectedCurationId,
149
+ setPulsedAnnotationId,
150
+ selectCardFromDocument,
151
+ runEditorHandOff,
152
+ openCardEdit,
153
+ } = deps;
154
+
155
+ return (intent: Intent): void => {
156
+ switch (intent.type) {
157
+ case "exit":
158
+ return void onExit?.(0);
159
+ case "status":
160
+ return controller.setStatus(intent.message);
161
+ case "move": {
162
+ const navigableCount = isDiff ? rows.length : display.length;
163
+ if (intent.to === "down") setCursor((current) => Math.min(navigableCount - 1, current + 1));
164
+ else if (intent.to === "up") setCursor((current) => Math.max(0, current - 1));
165
+ else if (intent.to === "top") setCursor(0);
166
+ else setCursor(navigableCount - 1);
167
+ return;
168
+ }
169
+ case "inboxMove": {
170
+ const navigableCount = inbox?.length ?? 0;
171
+ setInboxCursor((current) =>
172
+ intent.to === "down"
173
+ ? Math.min(navigableCount - 1, current + 1)
174
+ : Math.max(0, current - 1),
175
+ );
176
+ return;
177
+ }
178
+ case "openSession": {
179
+ const selected = inbox?.[inboxCursor];
180
+ if (selected) controller.open(selected.id);
181
+ return;
182
+ }
183
+ case "requestDeleteSession": {
184
+ const selected = inbox?.[inboxCursor];
185
+ if (selected)
186
+ setMode({
187
+ type: "confirmDelete",
188
+ sessionId: selected.id,
189
+ title: selected.artifact.meta.title ?? selected.id,
190
+ });
191
+ return;
192
+ }
193
+ case "openRename": {
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
+ });
204
+ }
205
+ case "confirmDialog": {
206
+ if (mode.type === "confirmDelete") controller.deleteSession(mode.sessionId);
207
+ else if (mode.type === "rename") renameAuthor(mode.authorId, mode.text.trim());
208
+ else if (mode.type === "nameSelf") controller.setSelfName(mode.text.trim());
209
+ return void setMode({ type: "normal" });
210
+ }
211
+ case "startSpan": {
212
+ const block = display[cursor];
213
+ if (!block?.work) return;
214
+ const span = startSpan(cursor, displayText(block));
215
+ if (span) setMode({ type: "span", span });
216
+ return;
217
+ }
218
+ case "spanKey":
219
+ if (mode.type === "span") {
220
+ const span = spanKey(
221
+ mode.span,
222
+ intent.name,
223
+ displayText(display[mode.span.displayIndex]!),
224
+ );
225
+ setMode({ type: "span", span });
226
+ }
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 = quickActionBody(action);
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
+ }
266
+ case "openCompose": {
267
+ liveInput.current = "";
268
+ if (intent.from === "span" && mode.type === "span") {
269
+ setMode({
270
+ type: "compose",
271
+ kind: intent.kind,
272
+ displayIndex: mode.span.displayIndex,
273
+ start: mode.span.start,
274
+ end: mode.span.end,
275
+ text: "",
276
+ });
277
+ } else if (isDiff) {
278
+ const row = rows[cursor];
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
+ });
288
+ } else {
289
+ // a mouse drag leaves a native selection; it wins over the cursor block
290
+ const native = planSheetRef.current?.readSelection() ?? null;
291
+ if (native) {
292
+ setMode({ type: "compose", kind: intent.kind, ...native, text: "" });
293
+ } else {
294
+ const block = display[cursor];
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
+ });
304
+ }
305
+ }
306
+ return;
307
+ }
308
+ case "openSubmit":
309
+ if (!session) return;
310
+ liveInput.current = "";
311
+ // the confirm card lives in the expanded review rail; a compact or hidden
312
+ // panel would swallow the whole submit flow, so force the rail open (live
313
+ // only - the saved panel preference is left untouched)
314
+ setReviewMode("expanded");
315
+ setRailTab("review");
316
+ return void setMode({ type: "submit", verdict: defaultVerdict(session), summary: "" });
317
+ case "share":
318
+ return controller.share();
319
+ case "cut":
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
+ }
334
+ case "edit":
335
+ return runEditorHandOff();
336
+ case "editCard":
337
+ if (focusedAnnotationId) openCardEdit(focusedAnnotationId);
338
+ return;
339
+ case "nextAnnotation":
340
+ case "prevAnnotation": {
341
+ // cycle open cards only - addressed ones are out of the rail
342
+ const annotations = (session?.annotations ?? []).filter(
343
+ (annotation) => !isAddressed(annotation),
344
+ );
345
+ if (!annotations.length) return;
346
+ const focusedIndex = annotations.findIndex(
347
+ (annotation) => annotation.id === focusedAnnotationId,
348
+ );
349
+ const nextIndex =
350
+ focusedIndex === -1
351
+ ? 0
352
+ : (focusedIndex + (intent.type === "nextAnnotation" ? 1 : -1) + annotations.length) %
353
+ annotations.length;
354
+ return void selectCardFromDocument(annotations[nextIndex]!.id);
355
+ }
356
+ case "walkStart":
357
+ return controller.walkStart();
358
+ case "walkForward":
359
+ return controller.walkForward();
360
+ case "walkBack":
361
+ return controller.walkBack();
362
+ case "walkLeave":
363
+ return controller.walkLeave();
364
+ case "removeAnnotation":
365
+ if (focusedAnnotationId) {
366
+ controller.removeAnnotation(focusedAnnotationId);
367
+ setFocusedAnnotationId(undefined);
368
+ }
369
+ return;
370
+ case "deselect":
371
+ planSheetRef.current?.clearSelection();
372
+ setFocusedAnnotationId(undefined);
373
+ setPulsedAnnotationId(null);
374
+ return;
375
+ case "closeOverlay":
376
+ return void setMode({ type: "normal" });
377
+ case "saveCompose": {
378
+ const body = liveInput.current.trim();
379
+ if (mode.type === "railEdit") {
380
+ if (session && body) controller.updateAnnotation(mode.id, body);
381
+ return void setMode({ type: "normal" });
382
+ }
383
+ if (mode.type !== "compose") return;
384
+ if (session && body) {
385
+ const annotationId = controller.annotate(
386
+ mode.kind,
387
+ mode.displayIndex,
388
+ mode.start,
389
+ mode.end,
390
+ body,
391
+ );
392
+ if (annotationId) setFocusedAnnotationId(annotationId);
393
+ }
394
+ return void setMode({ type: "normal" });
395
+ }
396
+ case "submitVerdict":
397
+ if (mode.type === "submit") controller.submit(mode.verdict, liveInput.current);
398
+ return void setMode({ type: "normal" });
399
+ case "cycleVerdict": {
400
+ if (mode.type !== "submit") return;
401
+ const verdictIndex =
402
+ (VERDICTS.indexOf(mode.verdict) + intent.direction + VERDICTS.length) % VERDICTS.length;
403
+ return void setMode({ ...mode, verdict: VERDICTS[verdictIndex]! });
404
+ }
405
+ case "finishReview":
406
+ return controller.finishReview();
407
+ case "optInAutoClose":
408
+ return controller.optInAutoClose();
409
+ case "dismissCompletion":
410
+ return controller.dismissCompletion();
411
+ case "cycleReviewPanel": {
412
+ const next = cycleReviewPanelMode(reviewMode);
413
+ setReviewMode(next);
414
+ return controller.saveReviewPanel({ mode: next });
415
+ }
416
+ case "resizeReviewPanel": {
417
+ if (reviewMode !== "expanded") return;
418
+ const next = resolveReviewWidth(
419
+ reviewWidth + intent.direction * REVIEW_RESIZE_STEP,
420
+ terminalWidth,
421
+ );
422
+ reviewWidthRef.current = next;
423
+ setReviewWidth(next);
424
+ return controller.saveReviewPanel({ width: next });
425
+ }
426
+ }
427
+ };
428
+ }
@@ -0,0 +1,152 @@
1
+ /** Config keys resolve to per-layer actions, and status hints derive from getActiveKeys() so rebinds surface. */
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import { DEFAULT_KEYS } from "./config";
5
+ import { KeyBindings } from "./key-bindings";
6
+
7
+ function bindings(
8
+ context: { overlay?: "none" | "walk" | "compose" | "submit"; spanMode?: boolean } = {},
9
+ ): KeyBindings {
10
+ const resolver = new KeyBindings(DEFAULT_KEYS);
11
+ resolver.setContext({ overlay: context.overlay ?? "none", spanMode: context.spanMode ?? false });
12
+ return resolver;
13
+ }
14
+
15
+ describe("grammar-layer resolution", () => {
16
+ const table: [name: string, shift: boolean, action: string | undefined][] = [
17
+ ["j", false, "down"],
18
+ ["down", false, "down"],
19
+ ["k", false, "up"],
20
+ ["g", false, "top"],
21
+ ["g", true, "bottom"],
22
+ ["v", false, "span"],
23
+ ["c", false, "comment"],
24
+ ["s", false, undefined],
25
+ ["x", false, "cut"],
26
+ ["e", false, "edit"],
27
+ ["n", false, "next_annotation"],
28
+ ["p", false, "prev_annotation"],
29
+ ["backspace", false, "delete_annotation"],
30
+ ["return", false, "submit"],
31
+ ["enter", false, "submit"],
32
+ ["q", false, "quit"],
33
+ ["z", false, undefined],
34
+ ];
35
+ for (const [name, shift, action] of table) {
36
+ test(`${shift ? "shift+" : ""}${name} -> ${action}`, () => {
37
+ expect(bindings().resolveAction({ name, shift })).toBe(action!);
38
+ });
39
+ }
40
+ });
41
+
42
+ describe("mode layers own their keys", () => {
43
+ test("span mode resolves the span verbs, not the grammar", () => {
44
+ // Arrange
45
+ const resolver = bindings({ spanMode: true });
46
+
47
+ // Assert
48
+ expect(resolver.resolveAction({ name: "l", shift: false })).toBe("span_grow");
49
+ expect(resolver.resolveAction({ name: "c", shift: false })).toBe("span_comment");
50
+ expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("span_cancel");
51
+ });
52
+
53
+ test("the compose overlay only knows save and cancel", () => {
54
+ // Arrange
55
+ const resolver = bindings({ overlay: "compose" });
56
+
57
+ // Assert
58
+ expect(resolver.resolveAction({ name: "return", shift: false })).toBe("save_compose");
59
+ expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("cancel_overlay");
60
+ expect(resolver.resolveAction({ name: "j", shift: false })).toBeUndefined();
61
+ });
62
+
63
+ test("the submit overlay adds the verdict arrows", () => {
64
+ // Arrange
65
+ const resolver = bindings({ overlay: "submit" });
66
+
67
+ // Assert
68
+ expect(resolver.resolveAction({ name: "left", shift: false })).toBe("cycle_verdict_left");
69
+ expect(resolver.resolveAction({ name: "right", shift: false })).toBe("cycle_verdict_right");
70
+ expect(resolver.resolveAction({ name: "return", shift: false })).toBe("submit_verdict");
71
+ });
72
+
73
+ test("the walk overlay owns the bracket stepping and leaves the grammar keys dead", () => {
74
+ // Arrange
75
+ const resolver = bindings({ overlay: "walk" });
76
+
77
+ // Assert
78
+ expect(resolver.resolveAction({ name: "]", shift: false })).toBe("walk_next");
79
+ expect(resolver.resolveAction({ name: "[", shift: false })).toBe("walk_prev");
80
+ expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("walk_leave");
81
+ expect(resolver.resolveAction({ name: "return", shift: false })).toBe("walk_submit");
82
+ expect(resolver.resolveAction({ name: "q", shift: false })).toBe("quit");
83
+ expect(resolver.resolveAction({ name: "j", shift: false })).toBeUndefined();
84
+ expect(resolver.resolveAction({ name: "c", shift: false })).toBeUndefined();
85
+ });
86
+ });
87
+
88
+ describe("getActiveKeys-generated status hints", () => {
89
+ test("normal mode hint matches the locked string", () => {
90
+ expect(bindings().statusHint("normal")).toBe(
91
+ "j/k move · v span · drag selects · c comment · x cut · e edit · n/p annotations · enter submit · q quit",
92
+ );
93
+ });
94
+
95
+ test("card-selected hint keeps the locked Cut label", () => {
96
+ expect(bindings().statusHint("card")).toBe(
97
+ "card · e edit · x Cut · n/p cards · esc deselect · enter submit",
98
+ );
99
+ });
100
+
101
+ test("span hint", () => {
102
+ expect(bindings({ spanMode: true }).statusHint("span")).toBe(
103
+ "span · l/h grow/shrink · w/b slide · $ end · c comment · esc",
104
+ );
105
+ });
106
+
107
+ test("compose hint", () => {
108
+ expect(bindings({ overlay: "compose" }).statusHint("compose")).toBe(
109
+ "typing · enter save · esc cancel",
110
+ );
111
+ });
112
+
113
+ test("submit hint", () => {
114
+ expect(bindings({ overlay: "submit" }).statusHint("submit")).toBe(
115
+ "verdict ←/→ · enter submit · esc cancel",
116
+ );
117
+ });
118
+
119
+ test("walk hint", () => {
120
+ expect(bindings({ overlay: "walk" }).statusHint("walk")).toBe(
121
+ "walk · ] next (marks viewed) · [ back · esc leave walk · q quit",
122
+ );
123
+ });
124
+
125
+ test("read-only hint", () => {
126
+ expect(bindings().statusHint("read-only")).toBe(
127
+ "observer - read-only · j/k move · n/p annotations · q quit",
128
+ );
129
+ });
130
+
131
+ test("collaborator hint annotates but shows no plan edit or submit", () => {
132
+ // Arrange / Act
133
+ const hint = bindings().statusHint("collaborator");
134
+
135
+ // Assert
136
+ expect(hint).toBe("j/k move · v span · drag selects · c comment · n/p annotations · q quit");
137
+ expect(hint).not.toContain("cut");
138
+ expect(hint).not.toContain("edit");
139
+ expect(hint).not.toContain("submit");
140
+ });
141
+
142
+ test("a rebound key shows its real binding in the hint", () => {
143
+ // Arrange
144
+ const resolver = new KeyBindings({ ...DEFAULT_KEYS, comment: ["m"] });
145
+ resolver.setContext({ overlay: "none", spanMode: false });
146
+
147
+ // Assert
148
+ expect(resolver.resolveAction({ name: "m", shift: false })).toBe("comment");
149
+ expect(resolver.resolveAction({ name: "c", shift: false })).toBeUndefined();
150
+ expect(resolver.statusHint("normal")).toContain("m comment");
151
+ });
152
+ });