@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.
- package/package.json +32 -24
- package/src/App.autoclose.test.tsx +137 -0
- package/src/App.diff.test.tsx +158 -22
- package/src/App.export.test.tsx +44 -37
- package/src/App.plan-review.test.tsx +632 -0
- package/src/App.readonly.test.tsx +60 -36
- package/src/App.rendering.test.tsx +132 -0
- package/src/App.review-panel.test.tsx +136 -0
- package/src/App.submit-confirm.test.tsx +220 -0
- package/src/App.test.tsx +128 -69
- package/src/App.toast.test.tsx +75 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +288 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +38 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +30 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +228 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +44 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +83 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +59 -0
- package/src/components/ConfirmCard.stories.tsx +56 -0
- package/src/components/ConfirmCard.tsx +135 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +355 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +62 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +58 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +74 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +389 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +78 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +101 -0
- package/src/components/ReviewRail.stories.tsx +220 -0
- package/src/components/ReviewRail.tsx +316 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +141 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +108 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +44 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +21 -0
- package/src/components/agent-launcher.tsx +123 -0
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +48 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +64 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +98 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +136 -0
- package/src/components/stories-app.tsx +123 -0
- package/src/components/stories.test.tsx +62 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +69 -0
- package/src/components/syntax-highlight.ts +92 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +36 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +430 -31
- package/src/config.ts +304 -26
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +143 -0
- package/src/editor.ts +123 -9
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +27 -0
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +30 -0
- package/src/intent-dispatch.test.ts +492 -0
- package/src/intent-dispatch.ts +428 -0
- package/src/key-bindings.test.ts +152 -0
- package/src/key-bindings.ts +424 -0
- package/src/keymap.test.ts +622 -0
- package/src/keymap.ts +339 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +24 -9
- package/src/serve.ts +7 -7
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +309 -0
- package/src/session-controller.ts +900 -0
- package/src/share.ts +113 -0
- package/src/test-support.test.ts +55 -0
- package/src/test-support.ts +187 -0
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +36 -15
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +34 -23
- package/src/view-plan.test.ts +294 -0
- package/src/view-plan.ts +408 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +70 -0
- package/src/view.test.ts +0 -157
- 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
|
+
});
|