@cueloop/client 0.1.0-alpha.14 → 0.1.0-alpha.16
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 +12 -10
- package/src/App.diff.test.tsx +1 -1
- package/src/App.submit-confirm.test.tsx +1 -1
- package/src/App.test.tsx +2 -2
- package/src/App.tsx +67 -12
- package/src/App.walk.test.tsx +208 -0
- package/src/components/CompletionOverlay.tsx +7 -8
- package/src/components/ConfirmCard.stories.tsx +15 -0
- package/src/components/ConfirmCard.tsx +8 -1
- package/src/components/WalkWizard.stories.tsx +89 -0
- package/src/components/WalkWizard.tsx +146 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +135 -5
- package/src/config.ts +1 -0
- package/src/key-bindings.test.ts +18 -1
- package/src/key-bindings.ts +22 -1
- package/src/keymap.test.ts +38 -1
- package/src/keymap.ts +28 -3
- package/src/session-controller.ts +92 -14
- package/src/theme.ts +19 -0
- package/src/walk.test.ts +110 -0
- package/src/walk.ts +65 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guided walk's focused card wizard (#83): one card per changed file
|
|
3
|
+
* floating over the dimmed diff, with a plain step count in the title - no
|
|
4
|
+
* progress bar. Below the main card a separate gray-bordered block carries
|
|
5
|
+
* the submitting agent's note for the file, when one exists. Past the last
|
|
6
|
+
* file the end card offers the submit action directly with honest counts.
|
|
7
|
+
* The keys live in the keymap's walk overlay; this component only renders.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React from "react";
|
|
11
|
+
import type { Theme } from "../theme";
|
|
12
|
+
import { useComponentTheme } from "./theme-context";
|
|
13
|
+
import type { WalkFile } from "../walk";
|
|
14
|
+
import { viewedCount } from "../walk";
|
|
15
|
+
import { Card } from "./primitives/Card";
|
|
16
|
+
import { Button } from "./primitives/Button";
|
|
17
|
+
import { Toolbar } from "./primitives/Toolbar";
|
|
18
|
+
|
|
19
|
+
export interface WalkWizardProps {
|
|
20
|
+
files: WalkFile[];
|
|
21
|
+
/** The wizard step; index === files.length renders the end card. */
|
|
22
|
+
index: number;
|
|
23
|
+
viewedPaths: ReadonlySet<string>;
|
|
24
|
+
/** The agent's note for the current file; the gray block renders only when set. */
|
|
25
|
+
note?: string;
|
|
26
|
+
/** Terminal width bounds the card width. */
|
|
27
|
+
terminalWidth: number;
|
|
28
|
+
/** End-card submit: leaves the walk and opens the rail confirm. */
|
|
29
|
+
onSubmitRequest: () => void;
|
|
30
|
+
onBack: () => void;
|
|
31
|
+
theme?: Theme;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The card never crowds the terminal edge nor stretches past reading width. */
|
|
35
|
+
function wizardWidth(terminalWidth: number): number {
|
|
36
|
+
return Math.min(72, Math.max(24, terminalWidth - 8));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Note rows from a crude wrap estimate, capped so the block stays a block. */
|
|
40
|
+
function noteRows(note: string, innerWidth: number): number {
|
|
41
|
+
return Math.min(3, Math.max(1, Math.ceil(note.length / Math.max(1, innerWidth))));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function WalkWizard({
|
|
45
|
+
files,
|
|
46
|
+
index,
|
|
47
|
+
viewedPaths,
|
|
48
|
+
note,
|
|
49
|
+
terminalWidth,
|
|
50
|
+
onSubmitRequest,
|
|
51
|
+
onBack,
|
|
52
|
+
theme,
|
|
53
|
+
}: WalkWizardProps): React.ReactNode {
|
|
54
|
+
const tokens = useComponentTheme(theme);
|
|
55
|
+
const width = wizardWidth(terminalWidth);
|
|
56
|
+
const viewed = viewedCount(files, viewedPaths);
|
|
57
|
+
const atEnd = index >= files.length;
|
|
58
|
+
const currentFile = files[index];
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<box
|
|
62
|
+
style={{
|
|
63
|
+
position: "absolute",
|
|
64
|
+
left: 0,
|
|
65
|
+
top: 0,
|
|
66
|
+
width: "100%",
|
|
67
|
+
height: "100%",
|
|
68
|
+
alignItems: "center",
|
|
69
|
+
justifyContent: "center",
|
|
70
|
+
flexDirection: "column",
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<box style={{ width, flexDirection: "column" }}>
|
|
74
|
+
{atEnd || !currentFile ? (
|
|
75
|
+
<Card
|
|
76
|
+
title=" walk complete "
|
|
77
|
+
contentRows={3}
|
|
78
|
+
borderColor={tokens.green}
|
|
79
|
+
backgroundColor={tokens.panel}
|
|
80
|
+
theme={theme}
|
|
81
|
+
>
|
|
82
|
+
<text fg={tokens.text}>
|
|
83
|
+
{viewed === files.length
|
|
84
|
+
? `every file viewed (${viewed}/${files.length})`
|
|
85
|
+
: `${viewed} of ${files.length} files viewed`}
|
|
86
|
+
</text>
|
|
87
|
+
<box style={{ height: 1 }} />
|
|
88
|
+
<Toolbar>
|
|
89
|
+
<Button variant="solid" marginRight={2} onPress={onSubmitRequest} theme={theme}>
|
|
90
|
+
{" Submit review "}
|
|
91
|
+
</Button>
|
|
92
|
+
<Button onPress={onBack} theme={theme}>
|
|
93
|
+
{" [ back "}
|
|
94
|
+
</Button>
|
|
95
|
+
</Toolbar>
|
|
96
|
+
</Card>
|
|
97
|
+
) : (
|
|
98
|
+
<>
|
|
99
|
+
<Card
|
|
100
|
+
title={` file ${index + 1} of ${files.length} · ${viewed} viewed `}
|
|
101
|
+
contentRows={3 + Math.max(1, currentFile.preview.length)}
|
|
102
|
+
borderColor={tokens.accent}
|
|
103
|
+
backgroundColor={tokens.panel}
|
|
104
|
+
theme={theme}
|
|
105
|
+
>
|
|
106
|
+
<text fg={tokens.text}>{currentFile.path}</text>
|
|
107
|
+
<text>
|
|
108
|
+
<span fg={tokens.green}>{`+${currentFile.added}`}</span>
|
|
109
|
+
<span fg={tokens.textDim}>{" "}</span>
|
|
110
|
+
<span fg={tokens.red}>{`-${currentFile.removed}`}</span>
|
|
111
|
+
<span fg={tokens.textDim}>{viewedPaths.has(currentFile.path) ? " · viewed" : ""}</span>
|
|
112
|
+
</text>
|
|
113
|
+
<box style={{ height: 1 }} />
|
|
114
|
+
{currentFile.preview.length === 0 ? (
|
|
115
|
+
<text fg={tokens.textDim}>(no changed lines)</text>
|
|
116
|
+
) : (
|
|
117
|
+
currentFile.preview.map((previewLine, previewIndex) => (
|
|
118
|
+
<text
|
|
119
|
+
key={previewIndex}
|
|
120
|
+
style={{ wrapMode: "none" }}
|
|
121
|
+
fg={previewLine.sign === "+" ? tokens.insFg : tokens.delFg}
|
|
122
|
+
>
|
|
123
|
+
{previewLine.sign + previewLine.text}
|
|
124
|
+
</text>
|
|
125
|
+
))
|
|
126
|
+
)}
|
|
127
|
+
</Card>
|
|
128
|
+
{note !== undefined ? (
|
|
129
|
+
<box style={{ marginTop: 1, flexDirection: "column" }}>
|
|
130
|
+
<Card
|
|
131
|
+
title=" agent note "
|
|
132
|
+
contentRows={noteRows(note, width - 4)}
|
|
133
|
+
borderColor={tokens.border}
|
|
134
|
+
backgroundColor={tokens.panel}
|
|
135
|
+
theme={theme}
|
|
136
|
+
>
|
|
137
|
+
<text fg={tokens.textMuted}>{note}</text>
|
|
138
|
+
</Card>
|
|
139
|
+
</box>
|
|
140
|
+
) : null}
|
|
141
|
+
</>
|
|
142
|
+
)}
|
|
143
|
+
</box>
|
|
144
|
+
</box>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -262,11 +262,11 @@ exports[`stories catalog CompletionOverlay/ApprovedPrompt renders, snapshots, an
|
|
|
262
262
|
|
|
263
263
|
|
|
264
264
|
|
|
265
|
-
|
|
265
|
+
review approved
|
|
266
266
|
|
|
267
267
|
The agent has your approval and is unblocked.
|
|
268
268
|
|
|
269
|
-
|
|
269
|
+
close [return] · return to plan [esc] · always [a]
|
|
270
270
|
|
|
271
271
|
|
|
272
272
|
|
|
@@ -289,13 +289,13 @@ exports[`stories catalog CompletionOverlay/FeedbackCounting renders, snapshots,
|
|
|
289
289
|
|
|
290
290
|
|
|
291
291
|
|
|
292
|
-
|
|
293
|
-
✎ feedback sent
|
|
292
|
+
feedback sent
|
|
294
293
|
|
|
295
294
|
The agent has your feedback and is unblocked.
|
|
295
|
+
returning to agent/worker-3 on close
|
|
296
296
|
exported to vault/cueloop/plan.md
|
|
297
297
|
|
|
298
|
-
|
|
298
|
+
close [return] · closing in 3s · return to plan [esc] · always [a]
|
|
299
299
|
|
|
300
300
|
|
|
301
301
|
|
|
@@ -1184,6 +1184,136 @@ exports[`stories catalog theme-context/ProviderSwap renders, snapshots, and carr
|
|
|
1184
1184
|
|
|
1185
1185
|
|
|
1186
1186
|
|
|
1187
|
+
|
|
1188
|
+
"
|
|
1189
|
+
`;
|
|
1190
|
+
|
|
1191
|
+
exports[`stories catalog ConfirmCard/WithViewedSummary renders, snapshots, and carries its colors: ConfirmCard/WithViewedSummary 1`] = `
|
|
1192
|
+
"╭─ submit review ─────────────────────╮
|
|
1193
|
+
│ 1 annotations · 0 blocking │
|
|
1194
|
+
│ 2/3 files viewed │
|
|
1195
|
+
│ │
|
|
1196
|
+
│ Comment [Approve] Changes │
|
|
1197
|
+
│ │
|
|
1198
|
+
│ summary for the agent (optional) │
|
|
1199
|
+
│ │
|
|
1200
|
+
│ Submit Cancel │
|
|
1201
|
+
╰─────────────────────────────────────╯
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
"
|
|
1207
|
+
`;
|
|
1208
|
+
|
|
1209
|
+
exports[`stories catalog WalkWizard/EndCard renders, snapshots, and carries its colors: WalkWizard/EndCard 1`] = `
|
|
1210
|
+
"
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
╭─ walk complete ──────────────────────────────────────────────────────╮
|
|
1221
|
+
│ every file viewed (3/3) │
|
|
1222
|
+
│ │
|
|
1223
|
+
│ Submit review [ back │
|
|
1224
|
+
╰──────────────────────────────────────────────────────────────────────╯
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
"
|
|
1235
|
+
`;
|
|
1236
|
+
|
|
1237
|
+
exports[`stories catalog WalkWizard/EndCardPartialPass renders, snapshots, and carries its colors: WalkWizard/EndCardPartialPass 1`] = `
|
|
1238
|
+
"
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
╭─ walk complete ──────────────────────────────────────────────────────╮
|
|
1249
|
+
│ 1 of 3 files viewed │
|
|
1250
|
+
│ │
|
|
1251
|
+
│ Submit review [ back │
|
|
1252
|
+
╰──────────────────────────────────────────────────────────────────────╯
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
"
|
|
1263
|
+
`;
|
|
1264
|
+
|
|
1265
|
+
exports[`stories catalog WalkWizard/FirstFile renders, snapshots, and carries its colors: WalkWizard/FirstFile 1`] = `
|
|
1266
|
+
"
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
╭─ file 1 of 3 · 0 viewed ─────────────────────────────────────────────╮
|
|
1275
|
+
│ packages/daemon/src/store.ts │
|
|
1276
|
+
│ +12 -3 │
|
|
1277
|
+
│ │
|
|
1278
|
+
│ +const viewed = new Set<string>(); │
|
|
1279
|
+
│ +record.viewedPaths = [...viewed]; │
|
|
1280
|
+
│ -// viewed tracking lives in the client only │
|
|
1281
|
+
╰──────────────────────────────────────────────────────────────────────╯
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
"
|
|
1291
|
+
`;
|
|
1292
|
+
|
|
1293
|
+
exports[`stories catalog WalkWizard/WithAgentNote renders, snapshots, and carries its colors: WalkWizard/WithAgentNote 1`] = `
|
|
1294
|
+
"
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
╭─ file 2 of 3 · 1 viewed ─────────────────────────────────────────────╮
|
|
1302
|
+
│ packages/client/src/walk.ts │
|
|
1303
|
+
│ +48 -0 │
|
|
1304
|
+
│ │
|
|
1305
|
+
│ +export function firstUnviewedIndex(files, viewed) { │
|
|
1306
|
+
╰──────────────────────────────────────────────────────────────────────╯
|
|
1307
|
+
|
|
1308
|
+
╭─ agent note ─────────────────────────────────────────────────────────╮
|
|
1309
|
+
│ New module: pure helpers that pick the next unviewed file. No IO, │
|
|
1310
|
+
│ unit tested. │
|
|
1311
|
+
╰──────────────────────────────────────────────────────────────────────╯
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
|
|
1187
1317
|
|
|
1188
1318
|
"
|
|
1189
1319
|
`;
|
package/src/config.ts
CHANGED
package/src/key-bindings.test.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
8
8
|
import { DEFAULT_KEYS } from "./config";
|
|
9
9
|
import { KeyBindings } from "./key-bindings";
|
|
10
10
|
|
|
11
|
-
function bindings(context: { overlay?: "none" | "compose" | "submit"; spanMode?: boolean } = {}): KeyBindings {
|
|
11
|
+
function bindings(context: { overlay?: "none" | "walk" | "compose" | "submit"; spanMode?: boolean } = {}): KeyBindings {
|
|
12
12
|
const resolver = new KeyBindings(DEFAULT_KEYS);
|
|
13
13
|
resolver.setContext({ overlay: context.overlay ?? "none", spanMode: context.spanMode ?? false });
|
|
14
14
|
return resolver;
|
|
@@ -62,6 +62,17 @@ describe("mode layers own their keys", () => {
|
|
|
62
62
|
expect(resolver.resolveAction({ name: "right", shift: false })).toBe("cycle_verdict_right");
|
|
63
63
|
expect(resolver.resolveAction({ name: "return", shift: false })).toBe("submit_verdict");
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
test("the walk overlay owns the bracket stepping and leaves the grammar keys dead", () => {
|
|
67
|
+
const resolver = bindings({ overlay: "walk" });
|
|
68
|
+
expect(resolver.resolveAction({ name: "]", shift: false })).toBe("walk_next");
|
|
69
|
+
expect(resolver.resolveAction({ name: "[", shift: false })).toBe("walk_prev");
|
|
70
|
+
expect(resolver.resolveAction({ name: "escape", shift: false })).toBe("walk_leave");
|
|
71
|
+
expect(resolver.resolveAction({ name: "return", shift: false })).toBe("walk_submit");
|
|
72
|
+
expect(resolver.resolveAction({ name: "q", shift: false })).toBe("quit");
|
|
73
|
+
expect(resolver.resolveAction({ name: "j", shift: false })).toBeUndefined();
|
|
74
|
+
expect(resolver.resolveAction({ name: "c", shift: false })).toBeUndefined();
|
|
75
|
+
});
|
|
65
76
|
});
|
|
66
77
|
|
|
67
78
|
describe("getActiveKeys-generated status hints", () => {
|
|
@@ -89,6 +100,12 @@ describe("getActiveKeys-generated status hints", () => {
|
|
|
89
100
|
expect(bindings({ overlay: "submit" }).statusHint("submit")).toBe("verdict ←/→ · ⏎ submit · esc cancel");
|
|
90
101
|
});
|
|
91
102
|
|
|
103
|
+
test("walk hint", () => {
|
|
104
|
+
expect(bindings({ overlay: "walk" }).statusHint("walk")).toBe(
|
|
105
|
+
"walk · ] next (marks viewed) · [ back · esc leave walk · q quit",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
92
109
|
test("read-only hint", () => {
|
|
93
110
|
expect(bindings().statusHint("read-only")).toBe("observer - read-only · j/k move · n/p annotations · q quit");
|
|
94
111
|
});
|
package/src/key-bindings.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface KeyLayerContext {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/** Hint templates keyed by the same view states the status line shows. */
|
|
27
|
-
export type HintMode = "normal" | "card" | "span" | "compose" | "submit" | "read-only";
|
|
27
|
+
export type HintMode = "normal" | "card" | "span" | "compose" | "submit" | "walk" | "read-only";
|
|
28
28
|
|
|
29
29
|
type HintEntry =
|
|
30
30
|
| { text: string }
|
|
@@ -70,6 +70,13 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
|
|
|
70
70
|
{ commands: ["submit_verdict"], label: "submit" },
|
|
71
71
|
{ commands: ["cancel_submit"], label: "cancel" },
|
|
72
72
|
],
|
|
73
|
+
walk: [
|
|
74
|
+
{ text: "walk" },
|
|
75
|
+
{ commands: ["walk_next"], label: "next (marks viewed)" },
|
|
76
|
+
{ commands: ["walk_prev"], label: "back" },
|
|
77
|
+
{ commands: ["walk_leave"], label: "leave walk" },
|
|
78
|
+
{ commands: ["quit"], label: "quit" },
|
|
79
|
+
],
|
|
73
80
|
"read-only": [
|
|
74
81
|
{ text: "observer - read-only" },
|
|
75
82
|
{ commands: ["down", "up"], label: "move" },
|
|
@@ -243,6 +250,20 @@ export class KeyBindings {
|
|
|
243
250
|
bindings: SPAN_COMMANDS.map(([key, command]) => ({ key, cmd: command })),
|
|
244
251
|
commands: SPAN_COMMANDS.map(([, command]) => record(command)),
|
|
245
252
|
});
|
|
253
|
+
// the walk wizard: prev/next stepping, leave, and submit from the end card
|
|
254
|
+
this.keymap.registerLayer({
|
|
255
|
+
priority: 20,
|
|
256
|
+
when: () => this.context.overlay === "walk",
|
|
257
|
+
bindings: [
|
|
258
|
+
{ key: "]", cmd: "walk_next" },
|
|
259
|
+
{ key: "[", cmd: "walk_prev" },
|
|
260
|
+
{ key: "escape", cmd: "walk_leave" },
|
|
261
|
+
{ key: "return", cmd: "walk_submit" },
|
|
262
|
+
{ key: "enter", cmd: "walk_submit" },
|
|
263
|
+
{ key: "q", cmd: "quit" },
|
|
264
|
+
],
|
|
265
|
+
commands: [record("walk_next"), record("walk_prev"), record("walk_leave"), record("walk_submit"), record("quit")],
|
|
266
|
+
});
|
|
246
267
|
this.keymap.registerLayer({
|
|
247
268
|
priority: 20,
|
|
248
269
|
when: () => this.context.overlay === "compose",
|
package/src/keymap.test.ts
CHANGED
|
@@ -20,6 +20,7 @@ function state(patch: Partial<KeyState> = {}): KeyState {
|
|
|
20
20
|
annotationCount: 1,
|
|
21
21
|
hasFocusedAnnotation: true,
|
|
22
22
|
cursorAnnotatable: true,
|
|
23
|
+
walkAtEnd: false,
|
|
23
24
|
...patch,
|
|
24
25
|
};
|
|
25
26
|
}
|
|
@@ -76,7 +77,7 @@ describe("completion overlay", () => {
|
|
|
76
77
|
[prompt, "j", []],
|
|
77
78
|
[counting, "return", [{ t: "finishReview" }]],
|
|
78
79
|
[counting, "q", [{ t: "finishReview" }]],
|
|
79
|
-
[counting, "a", []],
|
|
80
|
+
[counting, "a", [{ t: "optInAutoClose" }]],
|
|
80
81
|
[counting, "escape", [{ t: "dismissCompletion" }]],
|
|
81
82
|
];
|
|
82
83
|
for (const [s, name, expected] of table) {
|
|
@@ -228,6 +229,42 @@ describe("diff mode", () => {
|
|
|
228
229
|
});
|
|
229
230
|
});
|
|
230
231
|
|
|
232
|
+
describe("guided walk", () => {
|
|
233
|
+
test("w enters the walk in diff view; resolved and read-only sessions answer", () => {
|
|
234
|
+
expect(reduceKey(state({ view: "diff" }), key("w"))).toEqual([{ t: "walkStart" }]);
|
|
235
|
+
expect(reduceKey(state({ view: "diff", resolved: true }), key("w"))).toEqual([
|
|
236
|
+
{ t: "status", msg: "review submitted - read-only" },
|
|
237
|
+
]);
|
|
238
|
+
expect(reduceKey(state({ view: "diff", readOnly: true }), key("w"))).toEqual([
|
|
239
|
+
{ t: "status", msg: "observer - read-only" },
|
|
240
|
+
]);
|
|
241
|
+
expect(reduceKey(state(), key("w"))).toEqual([{ t: "status", msg: "the guided walk is a diff-review mode" }]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const walking = state({ view: "diff", overlay: "walk" });
|
|
245
|
+
const table: [string, Intent[]][] = [
|
|
246
|
+
["]", [{ t: "walkForward" }]],
|
|
247
|
+
["[", [{ t: "walkBack" }]],
|
|
248
|
+
["escape", [{ t: "walkLeave" }]],
|
|
249
|
+
["q", [{ t: "exit" }]],
|
|
250
|
+
// mid-walk return does nothing: submit lives on the end card only
|
|
251
|
+
["return", []],
|
|
252
|
+
["j", []],
|
|
253
|
+
["c", []],
|
|
254
|
+
];
|
|
255
|
+
for (const [name, expected] of table) {
|
|
256
|
+
test(`walking ${name} -> ${JSON.stringify(expected)}`, () => {
|
|
257
|
+
expect(reduceKey(walking, key(name))).toEqual(expected);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
test("the end card's return leaves the walk and opens the submit confirm", () => {
|
|
262
|
+
const atEnd = state({ view: "diff", overlay: "walk", walkAtEnd: true });
|
|
263
|
+
expect(reduceKey(atEnd, key("return"))).toEqual([{ t: "walkLeave" }, { t: "openSubmit" }]);
|
|
264
|
+
expect(reduceKey(atEnd, key("enter"))).toEqual([{ t: "walkLeave" }, { t: "openSubmit" }]);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
231
268
|
describe("duplicated-branch collapse", () => {
|
|
232
269
|
test("plan and diff produce the same intents for annotation nav, delete, and submit", () => {
|
|
233
270
|
for (const patch of [
|
package/src/keymap.ts
CHANGED
|
@@ -23,6 +23,10 @@ export type Intent =
|
|
|
23
23
|
| { t: "editCard" }
|
|
24
24
|
| { t: "nextAnn" }
|
|
25
25
|
| { t: "prevAnn" }
|
|
26
|
+
| { t: "walkStart" }
|
|
27
|
+
| { t: "walkForward" }
|
|
28
|
+
| { t: "walkBack" }
|
|
29
|
+
| { t: "walkLeave" }
|
|
26
30
|
| { t: "removeAnnotation" }
|
|
27
31
|
| { t: "deselect" }
|
|
28
32
|
| { t: "closeOverlay" }
|
|
@@ -43,10 +47,12 @@ export interface KeyState {
|
|
|
43
47
|
keys: Record<string, string[]>;
|
|
44
48
|
readOnly: boolean;
|
|
45
49
|
/** Layer that owns keys before the grammar runs. */
|
|
46
|
-
overlay: "none" | "compose" | "submit" | "completion-prompt" | "completion-counting";
|
|
50
|
+
overlay: "none" | "walk" | "compose" | "submit" | "completion-prompt" | "completion-counting";
|
|
47
51
|
view: "inbox" | "plan" | "diff";
|
|
48
52
|
/** Plan-only span selection sub-mode. */
|
|
49
53
|
spanMode: boolean;
|
|
54
|
+
/** The walk cursor sits on the end card - return offers the submit action. */
|
|
55
|
+
walkAtEnd: boolean;
|
|
50
56
|
resolved: boolean;
|
|
51
57
|
hasInboxItems: boolean;
|
|
52
58
|
annotationCount: number;
|
|
@@ -56,7 +62,7 @@ export interface KeyState {
|
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
/** Verbs that write session state; an observer never reaches their handlers. */
|
|
59
|
-
const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit"]);
|
|
65
|
+
const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit", "walk"]);
|
|
60
66
|
|
|
61
67
|
const SPAN_KEYS = ["l", "h", "w", "b", "$", "0"];
|
|
62
68
|
|
|
@@ -86,10 +92,23 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
86
92
|
}
|
|
87
93
|
if (state.overlay === "completion-prompt" || state.overlay === "completion-counting") {
|
|
88
94
|
if (name === "return" || name === "enter" || name === "q") return [{ t: "finishReview" }];
|
|
89
|
-
if (name === "a"
|
|
95
|
+
if (name === "a") return [{ t: "optInAutoClose" }];
|
|
90
96
|
if (name === "escape") return [{ t: "dismissCompletion" }];
|
|
91
97
|
return [];
|
|
92
98
|
}
|
|
99
|
+
// the walk wizard owns its keys while active: ] advances (marking the
|
|
100
|
+
// current file viewed), [ steps back, escape leaves keeping progress, and
|
|
101
|
+
// return on the end card hands over to the submit confirm
|
|
102
|
+
if (state.overlay === "walk") {
|
|
103
|
+
if (name === "]") return [{ t: "walkForward" }];
|
|
104
|
+
if (name === "[") return [{ t: "walkBack" }];
|
|
105
|
+
if (name === "escape") return [{ t: "walkLeave" }];
|
|
106
|
+
if ((name === "return" || name === "enter") && state.walkAtEnd) {
|
|
107
|
+
return [{ t: "walkLeave" }, { t: "openSubmit" }];
|
|
108
|
+
}
|
|
109
|
+
if (name === "q") return [{ t: "exit" }];
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
93
112
|
const action = resolvedAction ?? actionFor(state.keys, name, key.shift);
|
|
94
113
|
if (action === "quit") return [{ t: "exit" }];
|
|
95
114
|
// the ONE read-only rule: any mutating attempt answers instead of acting
|
|
@@ -114,6 +133,11 @@ function inboxGrammar(state: KeyState, name: string): Intent[] {
|
|
|
114
133
|
function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
|
|
115
134
|
const nav = navIntent(action);
|
|
116
135
|
if (nav) return nav;
|
|
136
|
+
if (action === "walk") {
|
|
137
|
+
// marking viewed writes the session record, so a resolved review answers
|
|
138
|
+
if (state.resolved) return status("review submitted - read-only");
|
|
139
|
+
return [{ t: "walkStart" }];
|
|
140
|
+
}
|
|
117
141
|
if (action === "comment") {
|
|
118
142
|
if (state.resolved) return status("review submitted - read-only");
|
|
119
143
|
if (!state.cursorAnnotatable) return status("move to a code line to comment");
|
|
@@ -139,6 +163,7 @@ function spanGrammar(name: string): Intent[] {
|
|
|
139
163
|
function planGrammar(state: KeyState, action: string | undefined, name: string): Intent[] {
|
|
140
164
|
const nav = navIntent(action);
|
|
141
165
|
if (nav) return nav;
|
|
166
|
+
if (action === "walk") return status("the guided walk is a diff-review mode");
|
|
142
167
|
if (name === "escape") return [{ t: "deselect" }];
|
|
143
168
|
if (action === "span") return state.cursorAnnotatable ? [{ t: "startSpan" }] : [];
|
|
144
169
|
if (action === "comment" || action === "suggest") {
|