@cueloop/client 0.1.0-alpha.20 → 0.1.0-alpha.22
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 +5 -5
- package/src/App.autoclose.test.tsx +29 -5
- package/src/App.diff.test.tsx +20 -0
- package/src/App.export.test.tsx +12 -8
- package/src/App.plan-review.test.tsx +182 -7
- package/src/App.readonly.test.tsx +19 -5
- package/src/App.rendering.test.tsx +21 -2
- package/src/App.review-panel.test.tsx +120 -0
- package/src/App.submit-confirm.test.tsx +57 -6
- package/src/App.test.tsx +62 -5
- package/src/App.tsx +147 -185
- package/src/App.walk.test.tsx +63 -8
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.tsx +66 -13
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +116 -0
- package/src/components/ReviewRail.tsx +30 -5
- package/src/components/__snapshots__/stories.test.tsx.snap +140 -0
- package/src/components/primitives/Card.tsx +2 -1
- package/src/components/primitives/Dialog.tsx +2 -1
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/stories-app.tsx +2 -1
- package/src/components/stories.test.tsx +5 -0
- package/src/config.test.ts +113 -1
- package/src/config.ts +58 -22
- package/src/editor.test.ts +24 -0
- package/src/herdr.test.ts +4 -0
- package/src/index.ts +2 -0
- package/src/integrations.test.ts +12 -0
- package/src/intent-dispatch.test.ts +213 -0
- package/src/intent-dispatch.ts +270 -0
- package/src/key-bindings.test.ts +16 -5
- package/src/keymap.test.ts +141 -9
- package/src/keymap.ts +54 -7
- package/src/review-panel.test.ts +111 -0
- package/src/review-panel.ts +76 -0
- package/src/serve.test.ts +7 -0
- package/src/session-controller.ts +39 -4
- package/src/share.ts +47 -0
- package/src/view-diff.test.ts +11 -0
- package/src/view-diff.ts +6 -6
- package/src/view-plan.test.ts +81 -0
- package/src/view-plan.ts +3 -0
- package/src/walk.test.ts +6 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The intent reducer in isolation: with the App component's dependencies
|
|
3
|
+
* mocked, each Intent maps to exactly the controller call or state update it
|
|
4
|
+
* should - the navigation clamps, the annotation wrap-around, the verdict
|
|
5
|
+
* cycle, and the submit-reveal fix, none of which needed a rendered TUI.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
9
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
10
|
+
import { createIntentDispatch, type IntentDispatchDeps } from "./intent-dispatch";
|
|
11
|
+
import type { DisplayBlock } from "./view-plan";
|
|
12
|
+
|
|
13
|
+
function block(text: string): DisplayBlock {
|
|
14
|
+
return { text, kind: "text", work: true } as unknown as DisplayBlock;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sessionWith(annotationIds: string[]): ReviewSession {
|
|
18
|
+
return {
|
|
19
|
+
annotations: annotationIds.map((id) => ({ id })),
|
|
20
|
+
workingCopy: undefined,
|
|
21
|
+
} as unknown as ReviewSession;
|
|
22
|
+
}
|
|
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 = {
|
|
27
|
+
setStatus: mock(),
|
|
28
|
+
open: mock(),
|
|
29
|
+
cut: mock(),
|
|
30
|
+
annotate: mock(),
|
|
31
|
+
updateAnnotation: mock(),
|
|
32
|
+
removeAnnotation: mock(),
|
|
33
|
+
walkStart: mock(),
|
|
34
|
+
walkForward: mock(),
|
|
35
|
+
walkBack: mock(),
|
|
36
|
+
walkLeave: mock(),
|
|
37
|
+
submit: mock(),
|
|
38
|
+
share: mock(),
|
|
39
|
+
finishReview: mock(),
|
|
40
|
+
optInAutoClose: mock(),
|
|
41
|
+
dismissCompletion: mock(),
|
|
42
|
+
saveReviewPanel: mock(),
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
controller: controller as unknown as IntentDispatchDeps["controller"],
|
|
46
|
+
onExit: mock(),
|
|
47
|
+
isDiff: false,
|
|
48
|
+
display: [],
|
|
49
|
+
rows: [],
|
|
50
|
+
cursor: 0,
|
|
51
|
+
inbox: null,
|
|
52
|
+
inboxCursor: 0,
|
|
53
|
+
mode: { type: "normal" },
|
|
54
|
+
session: null,
|
|
55
|
+
reviewMode: "expanded",
|
|
56
|
+
reviewWidth: 34,
|
|
57
|
+
terminalWidth: 120,
|
|
58
|
+
focusedAnnotationId: undefined,
|
|
59
|
+
liveInput: { current: "" },
|
|
60
|
+
reviewWidthRef: { current: 34 },
|
|
61
|
+
planSheetRef: { current: null },
|
|
62
|
+
setCursor: mock(),
|
|
63
|
+
setInboxCursor: mock(),
|
|
64
|
+
setMode: mock(),
|
|
65
|
+
setReviewMode: mock(),
|
|
66
|
+
setReviewWidth: mock(),
|
|
67
|
+
setRailTab: mock(),
|
|
68
|
+
setFocusedAnnotationId: mock(),
|
|
69
|
+
setPulsedAnnotationId: mock(),
|
|
70
|
+
selectCardFromDocument: mock(),
|
|
71
|
+
runEditorHandOff: mock(),
|
|
72
|
+
openCardEdit: mock(),
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("move", () => {
|
|
78
|
+
test("down clamps at the last row and up clamps at the first", () => {
|
|
79
|
+
// Arrange
|
|
80
|
+
const deps = makeDeps({ display: [block("a"), block("b"), block("c")] });
|
|
81
|
+
const dispatch = createIntentDispatch(deps);
|
|
82
|
+
|
|
83
|
+
// Act
|
|
84
|
+
dispatch({ type: "move", to: "down" });
|
|
85
|
+
|
|
86
|
+
// Assert
|
|
87
|
+
const advance = (deps.setCursor as ReturnType<typeof mock>).mock.calls[0]![0] as (current: number) => number;
|
|
88
|
+
expect(advance(2)).toBe(2); // already at the end, stays
|
|
89
|
+
expect(advance(0)).toBe(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("bottom jumps to the last navigable row", () => {
|
|
93
|
+
// Arrange
|
|
94
|
+
const deps = makeDeps({ display: [block("a"), block("b"), block("c")] });
|
|
95
|
+
const dispatch = createIntentDispatch(deps);
|
|
96
|
+
|
|
97
|
+
// Act
|
|
98
|
+
dispatch({ type: "move", to: "bottom" });
|
|
99
|
+
|
|
100
|
+
// Assert
|
|
101
|
+
expect(deps.setCursor).toHaveBeenCalledWith(2);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("annotation navigation", () => {
|
|
106
|
+
test("nextAnnotation wraps from the last annotation back to the first", () => {
|
|
107
|
+
// Arrange
|
|
108
|
+
const deps = makeDeps({ session: sessionWith(["a1", "a2", "a3"]), focusedAnnotationId: "a3" });
|
|
109
|
+
const dispatch = createIntentDispatch(deps);
|
|
110
|
+
|
|
111
|
+
// Act
|
|
112
|
+
dispatch({ type: "nextAnnotation" });
|
|
113
|
+
|
|
114
|
+
// Assert
|
|
115
|
+
expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a1");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("prevAnnotation wraps from the first annotation to the last", () => {
|
|
119
|
+
// Arrange
|
|
120
|
+
const deps = makeDeps({ session: sessionWith(["a1", "a2", "a3"]), focusedAnnotationId: "a1" });
|
|
121
|
+
const dispatch = createIntentDispatch(deps);
|
|
122
|
+
|
|
123
|
+
// Act
|
|
124
|
+
dispatch({ type: "prevAnnotation" });
|
|
125
|
+
|
|
126
|
+
// Assert
|
|
127
|
+
expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a3");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("cycling skips annotations a revision already addressed", () => {
|
|
131
|
+
// Arrange: a2 is addressed, so next from a1 lands on a3
|
|
132
|
+
const session = sessionWith(["a1", "a2", "a3"]);
|
|
133
|
+
(session.annotations[1] as { resolution?: object }).resolution = { revision: 2, source: "agent" };
|
|
134
|
+
const deps = makeDeps({ session, focusedAnnotationId: "a1" });
|
|
135
|
+
const dispatch = createIntentDispatch(deps);
|
|
136
|
+
|
|
137
|
+
// Act
|
|
138
|
+
dispatch({ type: "nextAnnotation" });
|
|
139
|
+
|
|
140
|
+
// Assert
|
|
141
|
+
expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a3");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("cycleVerdict", () => {
|
|
146
|
+
test("advances through the verdict list in the submit overlay", () => {
|
|
147
|
+
// Arrange
|
|
148
|
+
const deps = makeDeps({ mode: { type: "submit", verdict: "approve", summary: "" } });
|
|
149
|
+
const dispatch = createIntentDispatch(deps);
|
|
150
|
+
|
|
151
|
+
// Act
|
|
152
|
+
dispatch({ type: "cycleVerdict", direction: 1 });
|
|
153
|
+
|
|
154
|
+
// Assert
|
|
155
|
+
expect(deps.setMode).toHaveBeenCalledWith({ type: "submit", verdict: "request_changes", summary: "" });
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("openSubmit", () => {
|
|
160
|
+
test("force-opens the review rail so the confirm card can never be hidden", () => {
|
|
161
|
+
// Arrange
|
|
162
|
+
const deps = makeDeps({ session: sessionWith([]), reviewMode: "hidden" });
|
|
163
|
+
const dispatch = createIntentDispatch(deps);
|
|
164
|
+
|
|
165
|
+
// Act
|
|
166
|
+
dispatch({ type: "openSubmit" });
|
|
167
|
+
|
|
168
|
+
// Assert
|
|
169
|
+
expect(deps.setReviewMode).toHaveBeenCalledWith("expanded");
|
|
170
|
+
expect(deps.setRailTab).toHaveBeenCalledWith("review");
|
|
171
|
+
expect(deps.setMode).toHaveBeenCalledWith({ type: "submit", verdict: "approve", summary: "" });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("does nothing without a session", () => {
|
|
175
|
+
// Arrange
|
|
176
|
+
const deps = makeDeps({ session: null });
|
|
177
|
+
const dispatch = createIntentDispatch(deps);
|
|
178
|
+
|
|
179
|
+
// Act
|
|
180
|
+
dispatch({ type: "openSubmit" });
|
|
181
|
+
|
|
182
|
+
// Assert
|
|
183
|
+
expect(deps.setMode).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("share", () => {
|
|
188
|
+
test("dispatches to the controller's share", () => {
|
|
189
|
+
// Arrange
|
|
190
|
+
const deps = makeDeps();
|
|
191
|
+
const dispatch = createIntentDispatch(deps);
|
|
192
|
+
|
|
193
|
+
// Act
|
|
194
|
+
dispatch({ type: "share" });
|
|
195
|
+
|
|
196
|
+
// Assert
|
|
197
|
+
expect(deps.controller.share).toHaveBeenCalled();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("exit", () => {
|
|
202
|
+
test("calls the exit hook with 0", () => {
|
|
203
|
+
// Arrange
|
|
204
|
+
const deps = makeDeps();
|
|
205
|
+
const dispatch = createIntentDispatch(deps);
|
|
206
|
+
|
|
207
|
+
// Act
|
|
208
|
+
dispatch({ type: "exit" });
|
|
209
|
+
|
|
210
|
+
// Assert
|
|
211
|
+
expect(deps.onExit).toHaveBeenCalledWith(0);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,270 @@
|
|
|
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 type { PlanSheetHandle } from "./components/PlanSheet";
|
|
17
|
+
import { VERDICTS } from "./components/ConfirmCard";
|
|
18
|
+
import { REVIEW_RESIZE_STEP, cycleReviewPanelMode, resolveReviewWidth, type ReviewPanelMode } from "./review-panel";
|
|
19
|
+
|
|
20
|
+
/** The one overlay/mode the TUI is in; every compose/submit/edit flow is a variant. */
|
|
21
|
+
export type Mode =
|
|
22
|
+
| { type: "normal" }
|
|
23
|
+
| { type: "span"; span: SpanState }
|
|
24
|
+
| { type: "compose"; kind: "comment" | "suggestion"; displayIndex: number; start: number; end: number; text: string }
|
|
25
|
+
| { type: "railEdit"; id: string; text: string }
|
|
26
|
+
| { type: "submit"; verdict: VerdictKind; summary: string };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Annotations that still count as feedback: agent notes never do, and an
|
|
30
|
+
* annotation a revision already addressed is settled - it neither blocks the
|
|
31
|
+
* verdict default nor re-enters the next feedback document.
|
|
32
|
+
*/
|
|
33
|
+
export function reviewerAnnotations(session: ReviewSession) {
|
|
34
|
+
return session.annotations.filter((annotation) => !isAgentNote(annotation) && !isAddressed(annotation));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function defaultVerdict(session: ReviewSession): VerdictKind {
|
|
38
|
+
return reviewerAnnotations(session).length || session.workingCopy !== undefined ? "request_changes" : "approve";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Everything the reducer reads or writes, supplied fresh each render: the
|
|
43
|
+
* controller and exit hook, the derived reads (view rows, cursor, mode,
|
|
44
|
+
* session), the refs it pokes, the state setters, and the three App-owned
|
|
45
|
+
* selection helpers it composes.
|
|
46
|
+
*/
|
|
47
|
+
export interface IntentDispatchDeps {
|
|
48
|
+
controller: ReviewController;
|
|
49
|
+
onExit?: (code: number) => void;
|
|
50
|
+
|
|
51
|
+
isDiff: boolean;
|
|
52
|
+
display: DisplayBlock[];
|
|
53
|
+
rows: DiffRow[];
|
|
54
|
+
cursor: number;
|
|
55
|
+
inbox: ReviewSession[] | null;
|
|
56
|
+
inboxCursor: number;
|
|
57
|
+
mode: Mode;
|
|
58
|
+
session: ReviewSession | null;
|
|
59
|
+
reviewMode: ReviewPanelMode;
|
|
60
|
+
reviewWidth: number;
|
|
61
|
+
terminalWidth: number;
|
|
62
|
+
focusedAnnotationId: string | undefined;
|
|
63
|
+
|
|
64
|
+
liveInput: MutableRefObject<string>;
|
|
65
|
+
reviewWidthRef: MutableRefObject<number>;
|
|
66
|
+
planSheetRef: RefObject<PlanSheetHandle | null>;
|
|
67
|
+
|
|
68
|
+
setCursor: Dispatch<SetStateAction<number>>;
|
|
69
|
+
setInboxCursor: Dispatch<SetStateAction<number>>;
|
|
70
|
+
setMode: Dispatch<SetStateAction<Mode>>;
|
|
71
|
+
setReviewMode: Dispatch<SetStateAction<ReviewPanelMode>>;
|
|
72
|
+
setReviewWidth: Dispatch<SetStateAction<number>>;
|
|
73
|
+
setRailTab: Dispatch<SetStateAction<"review" | "agent">>;
|
|
74
|
+
setFocusedAnnotationId: Dispatch<SetStateAction<string | undefined>>;
|
|
75
|
+
setPulsedAnnotationId: Dispatch<SetStateAction<string | null>>;
|
|
76
|
+
|
|
77
|
+
selectCardFromDocument: (annotationId: string) => void;
|
|
78
|
+
runEditorHandOff: () => void;
|
|
79
|
+
openCardEdit: (annotationId: string) => void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Build the intent handler for one render from its dependency bag. */
|
|
83
|
+
export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent) => void {
|
|
84
|
+
const {
|
|
85
|
+
controller,
|
|
86
|
+
onExit,
|
|
87
|
+
isDiff,
|
|
88
|
+
display,
|
|
89
|
+
rows,
|
|
90
|
+
cursor,
|
|
91
|
+
inbox,
|
|
92
|
+
inboxCursor,
|
|
93
|
+
mode,
|
|
94
|
+
session,
|
|
95
|
+
reviewMode,
|
|
96
|
+
reviewWidth,
|
|
97
|
+
terminalWidth,
|
|
98
|
+
focusedAnnotationId,
|
|
99
|
+
liveInput,
|
|
100
|
+
reviewWidthRef,
|
|
101
|
+
planSheetRef,
|
|
102
|
+
setCursor,
|
|
103
|
+
setInboxCursor,
|
|
104
|
+
setMode,
|
|
105
|
+
setReviewMode,
|
|
106
|
+
setReviewWidth,
|
|
107
|
+
setRailTab,
|
|
108
|
+
setFocusedAnnotationId,
|
|
109
|
+
setPulsedAnnotationId,
|
|
110
|
+
selectCardFromDocument,
|
|
111
|
+
runEditorHandOff,
|
|
112
|
+
openCardEdit,
|
|
113
|
+
} = deps;
|
|
114
|
+
|
|
115
|
+
return (intent: Intent): void => {
|
|
116
|
+
switch (intent.type) {
|
|
117
|
+
case "exit":
|
|
118
|
+
return void onExit?.(0);
|
|
119
|
+
case "status":
|
|
120
|
+
return controller.setStatus(intent.message);
|
|
121
|
+
case "move": {
|
|
122
|
+
const navigableCount = isDiff ? rows.length : display.length;
|
|
123
|
+
if (intent.to === "down") setCursor((current) => Math.min(navigableCount - 1, current + 1));
|
|
124
|
+
else if (intent.to === "up") setCursor((current) => Math.max(0, current - 1));
|
|
125
|
+
else if (intent.to === "top") setCursor(0);
|
|
126
|
+
else setCursor(navigableCount - 1);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
case "inboxMove": {
|
|
130
|
+
const navigableCount = inbox?.length ?? 0;
|
|
131
|
+
setInboxCursor((current) => (intent.to === "down" ? Math.min(navigableCount - 1, current + 1) : Math.max(0, current - 1)));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
case "openSession": {
|
|
135
|
+
const selected = inbox?.[inboxCursor];
|
|
136
|
+
if (selected) controller.open(selected.id);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
case "startSpan": {
|
|
140
|
+
const block = display[cursor];
|
|
141
|
+
if (!block?.work) return;
|
|
142
|
+
const span = startSpan(cursor, displayText(block));
|
|
143
|
+
if (span) setMode({ type: "span", span });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
case "spanKey":
|
|
147
|
+
if (mode.type === "span") {
|
|
148
|
+
const span = spanKey(mode.span, intent.name, displayText(display[mode.span.displayIndex]!));
|
|
149
|
+
setMode({ type: "span", span });
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
case "openCompose": {
|
|
153
|
+
liveInput.current = "";
|
|
154
|
+
if (intent.from === "span" && mode.type === "span") {
|
|
155
|
+
setMode({
|
|
156
|
+
type: "compose",
|
|
157
|
+
kind: intent.kind,
|
|
158
|
+
displayIndex: mode.span.displayIndex,
|
|
159
|
+
start: mode.span.start,
|
|
160
|
+
end: mode.span.end,
|
|
161
|
+
text: "",
|
|
162
|
+
});
|
|
163
|
+
} else if (isDiff) {
|
|
164
|
+
const row = rows[cursor];
|
|
165
|
+
if (row) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: row.text.length, text: "" });
|
|
166
|
+
} else {
|
|
167
|
+
// a mouse drag leaves a native selection; it wins over the cursor block
|
|
168
|
+
const native = planSheetRef.current?.readSelection() ?? null;
|
|
169
|
+
if (native) {
|
|
170
|
+
setMode({ type: "compose", kind: intent.kind, ...native, text: "" });
|
|
171
|
+
} else {
|
|
172
|
+
const block = display[cursor];
|
|
173
|
+
if (block) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: displayText(block).length, text: "" });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
case "openSubmit":
|
|
179
|
+
if (!session) return;
|
|
180
|
+
liveInput.current = "";
|
|
181
|
+
// the confirm card lives in the expanded review rail; a compact or hidden
|
|
182
|
+
// panel would swallow the whole submit flow, so force the rail open (live
|
|
183
|
+
// only - the saved panel preference is left untouched)
|
|
184
|
+
setReviewMode("expanded");
|
|
185
|
+
setRailTab("review");
|
|
186
|
+
return void setMode({ type: "submit", verdict: defaultVerdict(session), summary: "" });
|
|
187
|
+
case "share":
|
|
188
|
+
return controller.share();
|
|
189
|
+
case "cut":
|
|
190
|
+
return controller.cut(cursor);
|
|
191
|
+
case "edit":
|
|
192
|
+
return runEditorHandOff();
|
|
193
|
+
case "editCard":
|
|
194
|
+
if (focusedAnnotationId) openCardEdit(focusedAnnotationId);
|
|
195
|
+
return;
|
|
196
|
+
case "nextAnnotation":
|
|
197
|
+
case "prevAnnotation": {
|
|
198
|
+
// cycle open cards only - addressed ones are out of the rail
|
|
199
|
+
const annotations = (session?.annotations ?? []).filter((annotation) => !isAddressed(annotation));
|
|
200
|
+
if (!annotations.length) return;
|
|
201
|
+
const focusedIndex = annotations.findIndex((annotation) => annotation.id === focusedAnnotationId);
|
|
202
|
+
const nextIndex =
|
|
203
|
+
focusedIndex === -1
|
|
204
|
+
? 0
|
|
205
|
+
: (focusedIndex + (intent.type === "nextAnnotation" ? 1 : -1) + annotations.length) % annotations.length;
|
|
206
|
+
return void selectCardFromDocument(annotations[nextIndex]!.id);
|
|
207
|
+
}
|
|
208
|
+
case "walkStart":
|
|
209
|
+
return controller.walkStart();
|
|
210
|
+
case "walkForward":
|
|
211
|
+
return controller.walkForward();
|
|
212
|
+
case "walkBack":
|
|
213
|
+
return controller.walkBack();
|
|
214
|
+
case "walkLeave":
|
|
215
|
+
return controller.walkLeave();
|
|
216
|
+
case "removeAnnotation":
|
|
217
|
+
if (focusedAnnotationId) {
|
|
218
|
+
controller.removeAnnotation(focusedAnnotationId);
|
|
219
|
+
setFocusedAnnotationId(undefined);
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
case "deselect":
|
|
223
|
+
planSheetRef.current?.clearSelection();
|
|
224
|
+
setFocusedAnnotationId(undefined);
|
|
225
|
+
setPulsedAnnotationId(null);
|
|
226
|
+
return;
|
|
227
|
+
case "closeOverlay":
|
|
228
|
+
return void setMode({ type: "normal" });
|
|
229
|
+
case "saveCompose": {
|
|
230
|
+
const body = liveInput.current.trim();
|
|
231
|
+
if (mode.type === "railEdit") {
|
|
232
|
+
if (session && body) controller.updateAnnotation(mode.id, body);
|
|
233
|
+
return void setMode({ type: "normal" });
|
|
234
|
+
}
|
|
235
|
+
if (mode.type !== "compose") return;
|
|
236
|
+
if (session && body) {
|
|
237
|
+
const annotationId = controller.annotate(mode.kind, mode.displayIndex, mode.start, mode.end, body);
|
|
238
|
+
if (annotationId) setFocusedAnnotationId(annotationId);
|
|
239
|
+
}
|
|
240
|
+
return void setMode({ type: "normal" });
|
|
241
|
+
}
|
|
242
|
+
case "submitVerdict":
|
|
243
|
+
if (mode.type === "submit") controller.submit(mode.verdict, liveInput.current);
|
|
244
|
+
return void setMode({ type: "normal" });
|
|
245
|
+
case "cycleVerdict": {
|
|
246
|
+
if (mode.type !== "submit") return;
|
|
247
|
+
const verdictIndex = (VERDICTS.indexOf(mode.verdict) + intent.direction + VERDICTS.length) % VERDICTS.length;
|
|
248
|
+
return void setMode({ ...mode, verdict: VERDICTS[verdictIndex]! });
|
|
249
|
+
}
|
|
250
|
+
case "finishReview":
|
|
251
|
+
return controller.finishReview();
|
|
252
|
+
case "optInAutoClose":
|
|
253
|
+
return controller.optInAutoClose();
|
|
254
|
+
case "dismissCompletion":
|
|
255
|
+
return controller.dismissCompletion();
|
|
256
|
+
case "cycleReviewPanel": {
|
|
257
|
+
const next = cycleReviewPanelMode(reviewMode);
|
|
258
|
+
setReviewMode(next);
|
|
259
|
+
return controller.saveReviewPanel({ mode: next });
|
|
260
|
+
}
|
|
261
|
+
case "resizeReviewPanel": {
|
|
262
|
+
if (reviewMode !== "expanded") return;
|
|
263
|
+
const next = resolveReviewWidth(reviewWidth + intent.direction * REVIEW_RESIZE_STEP, terminalWidth);
|
|
264
|
+
reviewWidthRef.current = next;
|
|
265
|
+
setReviewWidth(next);
|
|
266
|
+
return controller.saveReviewPanel({ width: next });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}
|
package/src/key-bindings.test.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Binding resolution and hint generation over @opentui/keymap: config keys
|
|
3
|
-
* resolve to action names per layer, and the status-line hints derive from
|
|
4
|
-
* getActiveKeys() so rebinds surface in the hint text.
|
|
5
|
-
*/
|
|
1
|
+
/** Config keys resolve to per-layer actions, and status hints derive from getActiveKeys() so rebinds surface. */
|
|
6
2
|
|
|
7
3
|
import { describe, expect, test } from "bun:test";
|
|
8
4
|
import { DEFAULT_KEYS } from "./config";
|
|
@@ -43,28 +39,40 @@ describe("grammar-layer resolution", () => {
|
|
|
43
39
|
|
|
44
40
|
describe("mode layers own their keys", () => {
|
|
45
41
|
test("span mode resolves the span verbs, not the grammar", () => {
|
|
42
|
+
// Arrange
|
|
46
43
|
const resolver = bindings({ spanMode: true });
|
|
44
|
+
|
|
45
|
+
// Assert
|
|
47
46
|
expect(resolver.resolveAction({ name: "l", shift: false })).toBe("span_grow");
|
|
48
47
|
expect(resolver.resolveAction({ name: "c", shift: false })).toBe("span_comment");
|
|
49
48
|
expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("span_cancel");
|
|
50
49
|
});
|
|
51
50
|
|
|
52
51
|
test("the compose overlay only knows save and cancel", () => {
|
|
52
|
+
// Arrange
|
|
53
53
|
const resolver = bindings({ overlay: "compose" });
|
|
54
|
+
|
|
55
|
+
// Assert
|
|
54
56
|
expect(resolver.resolveAction({ name: "return", shift: false })).toBe("save_compose");
|
|
55
57
|
expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("cancel_overlay");
|
|
56
58
|
expect(resolver.resolveAction({ name: "j", shift: false })).toBeUndefined();
|
|
57
59
|
});
|
|
58
60
|
|
|
59
61
|
test("the submit overlay adds the verdict arrows", () => {
|
|
62
|
+
// Arrange
|
|
60
63
|
const resolver = bindings({ overlay: "submit" });
|
|
64
|
+
|
|
65
|
+
// Assert
|
|
61
66
|
expect(resolver.resolveAction({ name: "left", shift: false })).toBe("cycle_verdict_left");
|
|
62
67
|
expect(resolver.resolveAction({ name: "right", shift: false })).toBe("cycle_verdict_right");
|
|
63
68
|
expect(resolver.resolveAction({ name: "return", shift: false })).toBe("submit_verdict");
|
|
64
69
|
});
|
|
65
70
|
|
|
66
71
|
test("the walk overlay owns the bracket stepping and leaves the grammar keys dead", () => {
|
|
72
|
+
// Arrange
|
|
67
73
|
const resolver = bindings({ overlay: "walk" });
|
|
74
|
+
|
|
75
|
+
// Assert
|
|
68
76
|
expect(resolver.resolveAction({ name: "]", shift: false })).toBe("walk_next");
|
|
69
77
|
expect(resolver.resolveAction({ name: "[", shift: false })).toBe("walk_prev");
|
|
70
78
|
expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("walk_leave");
|
|
@@ -111,8 +119,11 @@ describe("getActiveKeys-generated status hints", () => {
|
|
|
111
119
|
});
|
|
112
120
|
|
|
113
121
|
test("a rebound key shows its real binding in the hint", () => {
|
|
122
|
+
// Arrange
|
|
114
123
|
const resolver = new KeyBindings({ ...DEFAULT_KEYS, comment: ["m"] });
|
|
115
124
|
resolver.setContext({ overlay: "none", spanMode: false });
|
|
125
|
+
|
|
126
|
+
// Assert
|
|
116
127
|
expect(resolver.resolveAction({ name: "m", shift: false })).toBe("comment");
|
|
117
128
|
expect(resolver.resolveAction({ name: "c", shift: false })).toBeUndefined();
|
|
118
129
|
expect(resolver.statusHint("normal")).toContain("m comment");
|