@cueloop/client 0.1.0-alpha.5 → 0.1.0-alpha.50
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/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +36 -31
- 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 +672 -0
- package/src/App.prototype.test.tsx +155 -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 +218 -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 +289 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -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 +241 -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 +35 -0
- package/src/components/ConfirmCard.tsx +127 -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 +108 -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 +456 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +78 -0
- package/src/components/PrototypeSheet.stories.tsx +20 -0
- package/src/components/PrototypeSheet.tsx +358 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +101 -0
- package/src/components/ReviewRail.stories.tsx +218 -0
- package/src/components/ReviewRail.tsx +317 -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 +1857 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +49 -0
- package/src/components/agent-launcher.tsx +147 -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 +51 -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/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +181 -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/ghostty-terminal.test.ts +82 -0
- package/src/ghostty-terminal.ts +168 -0
- 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 +493 -0
- package/src/intent-dispatch.ts +428 -0
- package/src/key-bindings.test.ts +152 -0
- package/src/key-bindings.ts +430 -0
- package/src/keymap.test.ts +622 -0
- package/src/keymap.ts +339 -0
- package/src/kitty-image.test.ts +20 -0
- package/src/kitty-image.ts +75 -0
- package/src/prototype-browser.test.ts +55 -0
- package/src/prototype-browser.ts +243 -0
- package/src/pty.test.ts +78 -0
- package/src/pty.ts +222 -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 +914 -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 +38 -17
- 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.regression.test.ts +60 -0
- package/src/view-plan.test.ts +399 -0
- package/src/view-plan.ts +495 -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,622 @@
|
|
|
1
|
+
/** The keyboard grammar as data: every mode x key maps to a known intent list, gated once by the read-only rule. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { DEFAULT_KEYS } from "./config";
|
|
5
|
+
import { reduceKey, type Intent, type KeyState } from "./keymap";
|
|
6
|
+
|
|
7
|
+
function state(patch: Partial<KeyState> = {}): KeyState {
|
|
8
|
+
return {
|
|
9
|
+
keys: DEFAULT_KEYS,
|
|
10
|
+
readOnly: false,
|
|
11
|
+
overlay: "none",
|
|
12
|
+
view: "plan",
|
|
13
|
+
spanMode: false,
|
|
14
|
+
resolved: false,
|
|
15
|
+
hasInboxItems: true,
|
|
16
|
+
annotationCount: 1,
|
|
17
|
+
hasFocusedAnnotation: true,
|
|
18
|
+
cursorAnnotatable: true,
|
|
19
|
+
walkAtEnd: false,
|
|
20
|
+
...patch,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function key(
|
|
25
|
+
name: string,
|
|
26
|
+
shift = false,
|
|
27
|
+
meta = false,
|
|
28
|
+
): { name: string; shift: boolean; meta: boolean } {
|
|
29
|
+
return { name, shift, meta };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("compose overlay", () => {
|
|
33
|
+
// Arrange
|
|
34
|
+
const keyState = state({ overlay: "compose" });
|
|
35
|
+
const table: [string, Intent[]][] = [
|
|
36
|
+
["escape", [{ type: "closeOverlay" }]],
|
|
37
|
+
["return", [{ type: "saveCompose" }]],
|
|
38
|
+
["enter", [{ type: "saveCompose" }]],
|
|
39
|
+
["left", []],
|
|
40
|
+
["right", []],
|
|
41
|
+
["j", []],
|
|
42
|
+
["q", []],
|
|
43
|
+
];
|
|
44
|
+
for (const [name, expected] of table) {
|
|
45
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
46
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline in the composer, so the
|
|
51
|
+
// grammar must not save on them; only a bare ⏎ saves.
|
|
52
|
+
test("option/alt+return (meta) does not save - the textarea inserts a newline", () => {
|
|
53
|
+
expect(reduceKey(keyState, key("return", false, true))).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
test("shift+return does not save - the textarea inserts a newline", () => {
|
|
56
|
+
expect(reduceKey(keyState, key("return", true, false))).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("submit overlay", () => {
|
|
61
|
+
// Arrange
|
|
62
|
+
const keyState = state({ overlay: "submit" });
|
|
63
|
+
const table: [string, Intent[]][] = [
|
|
64
|
+
["escape", [{ type: "closeOverlay" }]],
|
|
65
|
+
["return", [{ type: "submitVerdict" }]],
|
|
66
|
+
["enter", [{ type: "submitVerdict" }]],
|
|
67
|
+
["left", [{ type: "cycleVerdict", direction: -1 }]],
|
|
68
|
+
["right", [{ type: "cycleVerdict", direction: 1 }]],
|
|
69
|
+
["j", []],
|
|
70
|
+
["q", []],
|
|
71
|
+
];
|
|
72
|
+
for (const [name, expected] of table) {
|
|
73
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
74
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("completion overlay", () => {
|
|
80
|
+
// Arrange
|
|
81
|
+
const prompt = state({ overlay: "completion-prompt" });
|
|
82
|
+
const counting = state({ overlay: "completion-counting" });
|
|
83
|
+
const table: [KeyState, string, Intent[]][] = [
|
|
84
|
+
[prompt, "return", [{ type: "finishReview" }]],
|
|
85
|
+
[prompt, "enter", [{ type: "finishReview" }]],
|
|
86
|
+
[prompt, "q", [{ type: "finishReview" }]],
|
|
87
|
+
[prompt, "a", [{ type: "optInAutoClose" }]],
|
|
88
|
+
[prompt, "escape", [{ type: "dismissCompletion" }]],
|
|
89
|
+
[prompt, "j", []],
|
|
90
|
+
[counting, "return", [{ type: "finishReview" }]],
|
|
91
|
+
[counting, "q", [{ type: "finishReview" }]],
|
|
92
|
+
[counting, "a", [{ type: "optInAutoClose" }]],
|
|
93
|
+
[counting, "escape", [{ type: "dismissCompletion" }]],
|
|
94
|
+
];
|
|
95
|
+
for (const [overlayState, name, expected] of table) {
|
|
96
|
+
test(`${overlayState.overlay} ${name} -> ${JSON.stringify(expected)}`, () => {
|
|
97
|
+
expect(reduceKey(overlayState, key(name))).toEqual(expected);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("inbox mode", () => {
|
|
103
|
+
// Arrange
|
|
104
|
+
const keyState = state({ view: "inbox" });
|
|
105
|
+
const table: [string, Intent[]][] = [
|
|
106
|
+
["j", [{ type: "inboxMove", to: "down" }]],
|
|
107
|
+
["down", [{ type: "inboxMove", to: "down" }]],
|
|
108
|
+
["k", [{ type: "inboxMove", to: "up" }]],
|
|
109
|
+
["up", [{ type: "inboxMove", to: "up" }]],
|
|
110
|
+
["return", [{ type: "openSession" }]],
|
|
111
|
+
["enter", [{ type: "openSession" }]],
|
|
112
|
+
["q", [{ type: "exit" }]],
|
|
113
|
+
["c", []],
|
|
114
|
+
["escape", []],
|
|
115
|
+
];
|
|
116
|
+
for (const [name, expected] of table) {
|
|
117
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
118
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
test("an empty inbox swallows every non-quit key", () => {
|
|
123
|
+
// Arrange
|
|
124
|
+
const empty = state({ view: "inbox", hasInboxItems: false });
|
|
125
|
+
|
|
126
|
+
// Assert
|
|
127
|
+
for (const name of ["j", "k", "down", "up", "return", "enter"]) {
|
|
128
|
+
expect(reduceKey(empty, key(name))).toEqual([]);
|
|
129
|
+
}
|
|
130
|
+
expect(reduceKey(empty, key("q"))).toEqual([{ type: "exit" }]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("collaborator (share) capabilities", () => {
|
|
135
|
+
test("can annotate: comment opens the composer", () => {
|
|
136
|
+
// Arrange
|
|
137
|
+
const collab = state({ canEditPlan: false, canSubmitVerdict: false });
|
|
138
|
+
|
|
139
|
+
// Act / Assert
|
|
140
|
+
expect(reduceKey(collab, key("c"), "comment")).toEqual([
|
|
141
|
+
{ type: "openCompose", kind: "comment", from: "cursor" },
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("can edit their own card: edit with a focused annotation rewrites it", () => {
|
|
146
|
+
// Arrange
|
|
147
|
+
const collab = state({ canEditPlan: false, hasFocusedAnnotation: true });
|
|
148
|
+
|
|
149
|
+
// Act / Assert
|
|
150
|
+
expect(reduceKey(collab, key("e"), "edit")).toEqual([{ type: "editCard" }]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("cannot edit the plan: cut/edit with no card is silent (no nag, no button)", () => {
|
|
154
|
+
// Arrange
|
|
155
|
+
const collab = state({ canEditPlan: false, hasFocusedAnnotation: false });
|
|
156
|
+
|
|
157
|
+
// Act / Assert
|
|
158
|
+
expect(reduceKey(collab, key("e"), "edit")).toEqual([]);
|
|
159
|
+
expect(reduceKey(collab, key("c"), "cut")).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("cannot submit a verdict: there is no agent on a share", () => {
|
|
163
|
+
// Arrange
|
|
164
|
+
const collab = state({ canSubmitVerdict: false });
|
|
165
|
+
|
|
166
|
+
// Act / Assert
|
|
167
|
+
expect(reduceKey(collab, key("x"), "submit")).toEqual([
|
|
168
|
+
{ type: "status", message: "shared view - your notes save as you go; q to leave" },
|
|
169
|
+
]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("cannot re-share: only the owner shares", () => {
|
|
173
|
+
// Arrange
|
|
174
|
+
const collab = state({ canShare: false });
|
|
175
|
+
|
|
176
|
+
// Act / Assert
|
|
177
|
+
expect(reduceKey(collab, key("S", true), "share")).toEqual([
|
|
178
|
+
{ type: "status", message: "only the plan owner can share" },
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("share", () => {
|
|
184
|
+
test("the owner's share key publishes", () => {
|
|
185
|
+
// Act / Assert
|
|
186
|
+
expect(reduceKey(state(), key("S", true), "share")).toEqual([{ type: "share" }]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("an observer cannot share", () => {
|
|
190
|
+
// Act / Assert
|
|
191
|
+
expect(reduceKey(state({ readOnly: true }), key("S", true), "share")).toEqual([
|
|
192
|
+
{ type: "status", message: "observer - read-only" },
|
|
193
|
+
]);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("plan normal mode", () => {
|
|
198
|
+
// Arrange
|
|
199
|
+
const keyState = state();
|
|
200
|
+
const table: [string, boolean, Intent[]][] = [
|
|
201
|
+
["j", false, [{ type: "move", to: "down" }]],
|
|
202
|
+
["down", false, [{ type: "move", to: "down" }]],
|
|
203
|
+
["k", false, [{ type: "move", to: "up" }]],
|
|
204
|
+
["up", false, [{ type: "move", to: "up" }]],
|
|
205
|
+
["g", false, [{ type: "move", to: "top" }]],
|
|
206
|
+
["g", true, [{ type: "move", to: "bottom" }]],
|
|
207
|
+
["v", false, [{ type: "startSpan" }]],
|
|
208
|
+
["c", false, [{ type: "openCompose", kind: "comment", from: "cursor" }]],
|
|
209
|
+
// the default state has a selected card, so Cut and edit act on the card
|
|
210
|
+
["x", false, [{ type: "removeAnnotation" }]],
|
|
211
|
+
["e", false, [{ type: "editCard" }]],
|
|
212
|
+
["n", false, [{ type: "nextAnnotation" }]],
|
|
213
|
+
["p", false, [{ type: "prevAnnotation" }]],
|
|
214
|
+
["backspace", false, [{ type: "removeAnnotation" }]],
|
|
215
|
+
["return", false, [{ type: "openSubmit" }]],
|
|
216
|
+
["q", false, [{ type: "exit" }]],
|
|
217
|
+
["escape", false, [{ type: "deselect" }]],
|
|
218
|
+
["z", false, []],
|
|
219
|
+
];
|
|
220
|
+
for (const [name, shift, expected] of table) {
|
|
221
|
+
test(`${shift ? "shift+" : ""}${name} -> ${JSON.stringify(expected)}`, () => {
|
|
222
|
+
expect(reduceKey(keyState, key(name, shift))).toEqual(expected);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
test("resolved sessions guard the mutating verbs", () => {
|
|
227
|
+
// Arrange
|
|
228
|
+
const resolvedState = state({ resolved: true });
|
|
229
|
+
|
|
230
|
+
// Assert
|
|
231
|
+
for (const name of ["c", "x", "e"]) {
|
|
232
|
+
expect(reduceKey(resolvedState, key(name))).toEqual([
|
|
233
|
+
{ type: "status", message: "review submitted - read-only" },
|
|
234
|
+
]);
|
|
235
|
+
}
|
|
236
|
+
expect(reduceKey(resolvedState, key("return"))).toEqual([]);
|
|
237
|
+
expect(reduceKey(resolvedState, key("backspace"))).toEqual([]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("u restores a rail removal (a cut block); a share viewer's is silent", () => {
|
|
241
|
+
// Assert - the undo intent leaves the target to the dispatcher
|
|
242
|
+
expect(reduceKey(state(), key("u"))).toEqual([{ type: "restoreCuration" }]);
|
|
243
|
+
expect(reduceKey(state({ resolved: true }), key("u"))).toEqual([
|
|
244
|
+
{ type: "status", message: "review submitted - read-only" },
|
|
245
|
+
]);
|
|
246
|
+
expect(reduceKey(state({ canEditPlan: false }), key("u"))).toEqual([]);
|
|
247
|
+
expect(reduceKey(state({ readOnly: true }), key("u"))).toEqual([
|
|
248
|
+
{ type: "status", message: "observer - read-only" },
|
|
249
|
+
]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("cut text cannot host a comment or a span", () => {
|
|
253
|
+
// Arrange
|
|
254
|
+
const cut = state({ cursorAnnotatable: false });
|
|
255
|
+
|
|
256
|
+
// Assert
|
|
257
|
+
expect(reduceKey(cut, key("c"))).toEqual([
|
|
258
|
+
{ type: "status", message: "text is cut - restore it first" },
|
|
259
|
+
]);
|
|
260
|
+
expect(reduceKey(cut, key("v"))).toEqual([]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("annotation navigation without annotations reports, delete without focus is silent", () => {
|
|
264
|
+
// Arrange
|
|
265
|
+
const none = state({ annotationCount: 0, hasFocusedAnnotation: false });
|
|
266
|
+
|
|
267
|
+
// Assert
|
|
268
|
+
expect(reduceKey(none, key("n"))).toEqual([{ type: "status", message: "no annotations" }]);
|
|
269
|
+
expect(reduceKey(none, key("p"))).toEqual([{ type: "status", message: "no annotations" }]);
|
|
270
|
+
expect(reduceKey(none, key("backspace"))).toEqual([]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("without a selected card x cuts the block and e opens the editor", () => {
|
|
274
|
+
// Arrange
|
|
275
|
+
const unfocused = state({ hasFocusedAnnotation: false });
|
|
276
|
+
|
|
277
|
+
// Assert
|
|
278
|
+
expect(reduceKey(unfocused, key("x"))).toEqual([{ type: "cut" }]);
|
|
279
|
+
expect(reduceKey(unfocused, key("e"))).toEqual([{ type: "edit" }]);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe("span mode", () => {
|
|
284
|
+
// Arrange
|
|
285
|
+
const keyState = state({ spanMode: true });
|
|
286
|
+
const table: [string, Intent[]][] = [
|
|
287
|
+
["l", [{ type: "spanKey", name: "l" }]],
|
|
288
|
+
["h", [{ type: "spanKey", name: "h" }]],
|
|
289
|
+
["w", [{ type: "spanKey", name: "w" }]],
|
|
290
|
+
["b", [{ type: "spanKey", name: "b" }]],
|
|
291
|
+
["$", [{ type: "spanKey", name: "$" }]],
|
|
292
|
+
["0", [{ type: "spanKey", name: "0" }]],
|
|
293
|
+
["c", [{ type: "openCompose", kind: "comment", from: "span" }]],
|
|
294
|
+
["x", [{ type: "spanCut" }]],
|
|
295
|
+
["a", [{ type: "openSpanActions" }]],
|
|
296
|
+
["escape", [{ type: "closeOverlay" }]],
|
|
297
|
+
["q", [{ type: "exit" }]],
|
|
298
|
+
["j", []],
|
|
299
|
+
];
|
|
300
|
+
for (const [name, expected] of table) {
|
|
301
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
302
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
test("an observer's span-mode mutations answer read-only", () => {
|
|
307
|
+
// Arrange
|
|
308
|
+
const observer = state({ spanMode: true, readOnly: true });
|
|
309
|
+
|
|
310
|
+
// Assert
|
|
311
|
+
expect(reduceKey(observer, key("x"))).toEqual([
|
|
312
|
+
{ type: "status", message: "observer - read-only" },
|
|
313
|
+
]);
|
|
314
|
+
expect(reduceKey(observer, key("a"))).toEqual([
|
|
315
|
+
{ type: "status", message: "observer - read-only" },
|
|
316
|
+
]);
|
|
317
|
+
expect(reduceKey(observer, key("c"))).toEqual([
|
|
318
|
+
{ type: "status", message: "observer - read-only" },
|
|
319
|
+
]);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("span-mode cut is owner-only, like the normal plan cut", () => {
|
|
323
|
+
// Arrange
|
|
324
|
+
const collaborator = state({ spanMode: true, canEditPlan: false });
|
|
325
|
+
|
|
326
|
+
// Assert
|
|
327
|
+
expect(reduceKey(collaborator, key("x"))).toEqual([]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("all span-mode mutations are blocked once the review is resolved", () => {
|
|
331
|
+
// Arrange
|
|
332
|
+
const resolved = state({ spanMode: true, resolved: true });
|
|
333
|
+
const readOnlyStatus: Intent[] = [{ type: "status", message: "review submitted - read-only" }];
|
|
334
|
+
|
|
335
|
+
// Assert - comment, cut, and quick-actions all answer read-only
|
|
336
|
+
expect(reduceKey(resolved, key("x"))).toEqual(readOnlyStatus);
|
|
337
|
+
expect(reduceKey(resolved, key("c"))).toEqual(readOnlyStatus);
|
|
338
|
+
expect(reduceKey(resolved, key("a"))).toEqual(readOnlyStatus);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("span-mode cut gates for an observer even when cut is rebound off x", () => {
|
|
342
|
+
// Arrange - the span sub-grammar hardwires x, so the read-only gate must catch it by name
|
|
343
|
+
const rebound = { ...DEFAULT_KEYS, cut: ["d"] };
|
|
344
|
+
const observer = state({ spanMode: true, readOnly: true, keys: rebound });
|
|
345
|
+
|
|
346
|
+
// Assert
|
|
347
|
+
expect(reduceKey(observer, key("x"))).toEqual([
|
|
348
|
+
{ type: "status", message: "observer - read-only" },
|
|
349
|
+
]);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe("span actions overlay", () => {
|
|
354
|
+
// Arrange
|
|
355
|
+
const keyState = state({ overlay: "spanActions" });
|
|
356
|
+
const table: [string, Intent[]][] = [
|
|
357
|
+
["j", [{ type: "moveSpanAction", direction: 1 }]],
|
|
358
|
+
["down", [{ type: "moveSpanAction", direction: 1 }]],
|
|
359
|
+
["k", [{ type: "moveSpanAction", direction: -1 }]],
|
|
360
|
+
["up", [{ type: "moveSpanAction", direction: -1 }]],
|
|
361
|
+
["return", [{ type: "pickSpanAction" }]],
|
|
362
|
+
["enter", [{ type: "pickSpanAction" }]],
|
|
363
|
+
["escape", [{ type: "closeSpanActions" }]],
|
|
364
|
+
["c", []],
|
|
365
|
+
["q", []],
|
|
366
|
+
];
|
|
367
|
+
for (const [name, expected] of table) {
|
|
368
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
369
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
describe("diff mode", () => {
|
|
375
|
+
// Arrange
|
|
376
|
+
const keyState = state({ view: "diff" });
|
|
377
|
+
const table: [string, Intent[]][] = [
|
|
378
|
+
["j", [{ type: "move", to: "down" }]],
|
|
379
|
+
["k", [{ type: "move", to: "up" }]],
|
|
380
|
+
["g", [{ type: "move", to: "top" }]],
|
|
381
|
+
["c", [{ type: "openCompose", kind: "comment", from: "cursor" }]],
|
|
382
|
+
["n", [{ type: "nextAnnotation" }]],
|
|
383
|
+
["p", [{ type: "prevAnnotation" }]],
|
|
384
|
+
["backspace", [{ type: "removeAnnotation" }]],
|
|
385
|
+
["return", [{ type: "openSubmit" }]],
|
|
386
|
+
["v", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
|
|
387
|
+
["x", [{ type: "rejectChange" }]],
|
|
388
|
+
["e", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
|
|
389
|
+
["q", [{ type: "exit" }]],
|
|
390
|
+
];
|
|
391
|
+
for (const [name, expected] of table) {
|
|
392
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
393
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
test("shift+x rejects the whole hunk under the cursor", () => {
|
|
398
|
+
// Assert
|
|
399
|
+
expect(reduceKey(keyState, key("x", true))).toEqual([{ type: "rejectHunk" }]);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("curation is owner-only and locked once resolved", () => {
|
|
403
|
+
// Assert - a resolved diff answers before curating
|
|
404
|
+
expect(reduceKey(state({ view: "diff", resolved: true }), key("x"))).toEqual([
|
|
405
|
+
{ type: "status", message: "review submitted - read-only" },
|
|
406
|
+
]);
|
|
407
|
+
// a collaborator (canEditPlan false) cannot curate hunks
|
|
408
|
+
expect(reduceKey(state({ view: "diff", canEditPlan: false }), key("x"))).toEqual([
|
|
409
|
+
{ type: "status", message: "only the diff owner can curate hunks" },
|
|
410
|
+
]);
|
|
411
|
+
// an observer's mutating attempt is stopped by the read-only gate
|
|
412
|
+
expect(reduceKey(state({ view: "diff", readOnly: true }), key("x"))).toEqual([
|
|
413
|
+
{ type: "status", message: "observer - read-only" },
|
|
414
|
+
]);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("u restores a curated-out item, owner-only and locked once resolved", () => {
|
|
418
|
+
// Assert - the undo intent leaves the target to the dispatcher
|
|
419
|
+
expect(reduceKey(keyState, key("u"))).toEqual([{ type: "restoreCuration" }]);
|
|
420
|
+
expect(reduceKey(state({ view: "diff", resolved: true }), key("u"))).toEqual([
|
|
421
|
+
{ type: "status", message: "review submitted - read-only" },
|
|
422
|
+
]);
|
|
423
|
+
expect(reduceKey(state({ view: "diff", canEditPlan: false }), key("u"))).toEqual([
|
|
424
|
+
{ type: "status", message: "only the diff owner can curate hunks" },
|
|
425
|
+
]);
|
|
426
|
+
expect(reduceKey(state({ view: "diff", readOnly: true }), key("u"))).toEqual([
|
|
427
|
+
{ type: "status", message: "observer - read-only" },
|
|
428
|
+
]);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("comment guards: resolved first, then non-code rows", () => {
|
|
432
|
+
// Assert
|
|
433
|
+
expect(reduceKey(state({ view: "diff", resolved: true }), key("c"))).toEqual([
|
|
434
|
+
{ type: "status", message: "review submitted - read-only" },
|
|
435
|
+
]);
|
|
436
|
+
expect(reduceKey(state({ view: "diff", cursorAnnotatable: false }), key("c"))).toEqual([
|
|
437
|
+
{ type: "status", message: "move to a code line to comment" },
|
|
438
|
+
]);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe("guided walk", () => {
|
|
443
|
+
test("w enters the walk in diff view; resolved and read-only sessions answer", () => {
|
|
444
|
+
// Assert
|
|
445
|
+
expect(reduceKey(state({ view: "diff" }), key("w"))).toEqual([{ type: "walkStart" }]);
|
|
446
|
+
expect(reduceKey(state({ view: "diff", resolved: true }), key("w"))).toEqual([
|
|
447
|
+
{ type: "status", message: "review submitted - read-only" },
|
|
448
|
+
]);
|
|
449
|
+
expect(reduceKey(state({ view: "diff", readOnly: true }), key("w"))).toEqual([
|
|
450
|
+
{ type: "status", message: "observer - read-only" },
|
|
451
|
+
]);
|
|
452
|
+
expect(reduceKey(state(), key("w"))).toEqual([
|
|
453
|
+
{ type: "status", message: "the guided walk is a diff-review mode" },
|
|
454
|
+
]);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// Arrange
|
|
458
|
+
const walking = state({ view: "diff", overlay: "walk" });
|
|
459
|
+
const table: [string, Intent[]][] = [
|
|
460
|
+
["]", [{ type: "walkForward" }]],
|
|
461
|
+
["[", [{ type: "walkBack" }]],
|
|
462
|
+
["escape", [{ type: "walkLeave" }]],
|
|
463
|
+
["q", [{ type: "exit" }]],
|
|
464
|
+
// mid-walk return does nothing: submit lives on the end card only
|
|
465
|
+
["return", []],
|
|
466
|
+
["j", []],
|
|
467
|
+
["c", []],
|
|
468
|
+
];
|
|
469
|
+
for (const [name, expected] of table) {
|
|
470
|
+
test(`walking ${name} -> ${JSON.stringify(expected)}`, () => {
|
|
471
|
+
expect(reduceKey(walking, key(name))).toEqual(expected);
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
test("the end card's return leaves the walk and opens the submit confirm", () => {
|
|
476
|
+
// Arrange
|
|
477
|
+
const atEnd = state({ view: "diff", overlay: "walk", walkAtEnd: true });
|
|
478
|
+
|
|
479
|
+
// Assert
|
|
480
|
+
expect(reduceKey(atEnd, key("return"))).toEqual([
|
|
481
|
+
{ type: "walkLeave" },
|
|
482
|
+
{ type: "openSubmit" },
|
|
483
|
+
]);
|
|
484
|
+
expect(reduceKey(atEnd, key("enter"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
describe("review panel controls", () => {
|
|
489
|
+
const table: [string, Intent[]][] = [
|
|
490
|
+
["b", [{ type: "cycleReviewPanel" }]],
|
|
491
|
+
["]", [{ type: "resizeReviewPanel", direction: 1 }]],
|
|
492
|
+
["[", [{ type: "resizeReviewPanel", direction: -1 }]],
|
|
493
|
+
];
|
|
494
|
+
for (const view of ["plan", "diff"] as const) {
|
|
495
|
+
for (const [name, expected] of table) {
|
|
496
|
+
test(`${view} ${name} -> ${JSON.stringify(expected)}`, () => {
|
|
497
|
+
expect(reduceKey(state({ view }), key(name))).toEqual(expected);
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
test("observers may still collapse and resize the panel (it is view state, not a mutation)", () => {
|
|
503
|
+
// Arrange
|
|
504
|
+
const observer = state({ readOnly: true });
|
|
505
|
+
|
|
506
|
+
// Assert
|
|
507
|
+
expect(reduceKey(observer, key("b"))).toEqual([{ type: "cycleReviewPanel" }]);
|
|
508
|
+
expect(reduceKey(observer, key("]"))).toEqual([{ type: "resizeReviewPanel", direction: 1 }]);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("span mode still owns b as a span verb, not a panel cycle", () => {
|
|
512
|
+
// Assert
|
|
513
|
+
expect(reduceKey(state({ spanMode: true }), key("b"))).toEqual([
|
|
514
|
+
{ type: "spanKey", name: "b" },
|
|
515
|
+
]);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("the walk overlay keeps the brackets for stepping", () => {
|
|
519
|
+
// Arrange
|
|
520
|
+
const walking = state({ view: "diff", overlay: "walk" });
|
|
521
|
+
|
|
522
|
+
// Assert
|
|
523
|
+
expect(reduceKey(walking, key("]"))).toEqual([{ type: "walkForward" }]);
|
|
524
|
+
expect(reduceKey(walking, key("["))).toEqual([{ type: "walkBack" }]);
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
describe("duplicated-branch collapse", () => {
|
|
529
|
+
test("plan and diff produce the same intents for annotation nav, delete, and submit", () => {
|
|
530
|
+
for (const patch of [
|
|
531
|
+
{},
|
|
532
|
+
{ annotationCount: 0, hasFocusedAnnotation: false },
|
|
533
|
+
{ resolved: true },
|
|
534
|
+
] as Partial<KeyState>[]) {
|
|
535
|
+
for (const name of ["n", "p", "backspace", "return"]) {
|
|
536
|
+
const plan = reduceKey(state(patch), key(name));
|
|
537
|
+
const diff = reduceKey(state({ ...patch, view: "diff" }), key(name));
|
|
538
|
+
expect(diff).toEqual(plan);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
describe("read-only filter", () => {
|
|
545
|
+
test("every mutating key answers observer - read-only, in plan and diff", () => {
|
|
546
|
+
for (const view of ["plan", "diff"] as const) {
|
|
547
|
+
const keyState = state({ view, readOnly: true });
|
|
548
|
+
for (const name of ["c", "x", "e", "backspace", "return", "enter"]) {
|
|
549
|
+
expect(reduceKey(keyState, key(name))).toEqual([
|
|
550
|
+
{ type: "status", message: "observer - read-only" },
|
|
551
|
+
]);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("navigation and annotation focus still work for observers", () => {
|
|
557
|
+
// Arrange
|
|
558
|
+
const keyState = state({ readOnly: true });
|
|
559
|
+
|
|
560
|
+
// Assert
|
|
561
|
+
expect(reduceKey(keyState, key("j"))).toEqual([{ type: "move", to: "down" }]);
|
|
562
|
+
expect(reduceKey(keyState, key("n"))).toEqual([{ type: "nextAnnotation" }]);
|
|
563
|
+
expect(reduceKey(keyState, key("q"))).toEqual([{ type: "exit" }]);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("span-mode c is gated by key name even under a rebound keymap", () => {
|
|
567
|
+
// Arrange
|
|
568
|
+
const rebound = { ...DEFAULT_KEYS, comment: ["m"] };
|
|
569
|
+
const keyState = state({ spanMode: true, readOnly: true, keys: rebound });
|
|
570
|
+
|
|
571
|
+
// Assert
|
|
572
|
+
expect(reduceKey(keyState, key("c"))).toEqual([
|
|
573
|
+
{ type: "status", message: "observer - read-only" },
|
|
574
|
+
]);
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
describe("inbox delete", () => {
|
|
579
|
+
test("d on a session requests delete", () => {
|
|
580
|
+
// Arrange / Act / Assert
|
|
581
|
+
expect(reduceKey(state({ view: "inbox" }), key("d"))).toEqual([
|
|
582
|
+
{ type: "requestDeleteSession" },
|
|
583
|
+
]);
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
describe("confirm overlay", () => {
|
|
588
|
+
// Arrange
|
|
589
|
+
const keyState = state({ overlay: "confirm" });
|
|
590
|
+
const table: [string, Intent[]][] = [
|
|
591
|
+
["return", [{ type: "confirmDialog" }]],
|
|
592
|
+
["enter", [{ type: "confirmDialog" }]],
|
|
593
|
+
["escape", [{ type: "closeOverlay" }]],
|
|
594
|
+
["j", []],
|
|
595
|
+
["d", []],
|
|
596
|
+
];
|
|
597
|
+
for (const [name, expected] of table) {
|
|
598
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
599
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
describe("rename grammar", () => {
|
|
605
|
+
test("r on a focused note opens rename", () => {
|
|
606
|
+
// Arrange / Act / Assert
|
|
607
|
+
expect(reduceKey(state({ hasFocusedAnnotation: true }), key("r"))).toEqual([
|
|
608
|
+
{ type: "openRename" },
|
|
609
|
+
]);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("prompt overlay routes ⏎ to confirm and esc to cancel", () => {
|
|
613
|
+
// Arrange / Act / Assert
|
|
614
|
+
expect(reduceKey(state({ overlay: "prompt" }), key("return"))).toEqual([
|
|
615
|
+
{ type: "confirmDialog" },
|
|
616
|
+
]);
|
|
617
|
+
expect(reduceKey(state({ overlay: "prompt" }), key("escape"))).toEqual([
|
|
618
|
+
{ type: "closeOverlay" },
|
|
619
|
+
]);
|
|
620
|
+
expect(reduceKey(state({ overlay: "prompt" }), key("a"))).toEqual([]);
|
|
621
|
+
});
|
|
622
|
+
});
|